diff --git a/resources/boost/skills/models/SKILL.md b/resources/boost/skills/models/SKILL.md index 6df6e77..dcdc46f 100644 --- a/resources/boost/skills/models/SKILL.md +++ b/resources/boost/skills/models/SKILL.md @@ -1,6 +1,6 @@ --- name: models -description: Eloquent model conventions covering mass assignment, casts, relationships, section headers, and activity logging. Every model must follow these structural rules. +description: Eloquent model conventions for mass assignment, casts, relationship naming, activity logging, and mandatory model tests (CRUD + relations). compatible_agents: - architect - implement @@ -14,7 +14,7 @@ compatible_agents: - When creating a new Eloquent model in `app/Models/`. - When refactoring existing models to align with mass-assignment, casting, and logging conventions. -- When reviewing models for consistency in relationships, helpers, and activity logging. +- When reviewing models for consistency in relationships, helpers, activity logging, and model tests. ## When NOT to Apply @@ -27,6 +27,7 @@ compatible_agents: - Database schema and migrations for the model’s table exist or are being designed. - `spatie/laravel-activitylog` is installed and configured for activity logging. - "Business models" means models representing core domain records with audit value (for example invoices, orders, payments). Apply `LogsActivity` to these models by default. +- A factory exists (or is created) for the model and any related models used in tests. ## Process @@ -48,18 +49,57 @@ compatible_agents: ### 3. Define Relationships and Helpers - Use typed return types on all relationship methods (`HasMany`, `BelongsTo`, etc.). +- Follow Laravel relationship naming conventions: + - Use singular names for single-record relations (`belongsTo`, `hasOne`, `morphOne`). + - Use plural names for multi-record relations (`hasMany`, `belongsToMany`, `morphMany`). +- Method names must use `camelCase` based on the related model name (for example, `pipelineSteps()` for `PipelineStep`). +- Avoid generic relation names like `steps()`, `runs()`, `items()`, or `attachments()` when they hide model intent. - Group related sections of the model with comment headers such as: - `// --- Relationships ---` - `// --- Status Helpers ---` - `// --- Activity Log ---` - Keep domain-specific helper methods focused and clearly named (e.g., `isDraft()`, `isPaid()`). +### 3.1 Required Relationship Renames (Canonical Examples) + +All relationship renames follow this convention: method name = `camelCase(RelatedModelName)` with singular/plural matching relation cardinality. + +| Model | Old Method | New Method | +| --- | --- | --- | +| Pipeline | `steps()` | `pipelineSteps()` | +| Pipeline | `runs()` | `pipelineRuns()` | +| PipelineStep | `stepRuns()` | `pipelineStepRuns()` | +| PipelineRun | `stepRuns()` | `pipelineStepRuns()` | +| PipelineTemplate | `steps()` | `pipelineSteps()` | +| Inbox | `items()` | `inboxItems()` | +| Inbox | `serviceUsers()` | `inboxServiceUsers()` | +| Inbox | `importConfigs()` | `inboxImportConfigs()` | +| InboxItem | `importConfig()` | `inboxImportConfig()` | +| InboxItem | `sections()` | `inboxItemSections()` | +| ProviderType | `templates()` | `providerTypeTemplates()` | +| Prompt | `attachments()` | `promptAttachments()` | + ### 4. Ensure Testability and Factories - Create a corresponding factory for every model under `database/factories/`. - Ensure factories cover required attributes and common state variants. - Prefer explicit factory states for common statuses (`->draft()`, `->paid()`, `->archived()`) to match model helpers. +### 5. Write Mandatory Model Tests (CRUD + All Relations) + +- Add a dedicated model test file under `tests/Unit/Models/` (or the project-standard model-test location). +- Use Pest syntax unless the code area is explicitly standardized on class-based PHPUnit. +- Cover all CRUD operations: + - **Create**: persist model with factory and assert DB row exists. + - **Read**: retrieve model and assert expected attributes/casts. + - **Update**: change persisted data and assert DB reflects updates. + - **Delete**: delete model and assert row is missing/soft-deleted as expected. +- Test every relationship method defined on the model: + - Assert relation returns the correct relation class (`HasMany`, `BelongsTo`, etc.). + - Assert related records can be created/attached through the relation. + - Assert retrieval returns expected related models/count. +- Include at least one helper/cast assertion for domain behavior (for example `isDraft()` and enum/date casts). + ## Examples ```php @@ -87,7 +127,7 @@ class Invoice extends Model // --- Relationships --- - public function lines(): HasMany + public function invoiceLines(): HasMany { return $this->hasMany(InvoiceLine::class); } @@ -116,6 +156,56 @@ class Invoice extends Model } ``` +```php +// tests/Unit/Models/InvoiceTest.php +use App\Enums\Status; +use App\Models\Invoice; +use App\Models\InvoiceLine; +use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Foundation\Testing\RefreshDatabase; + +uses(RefreshDatabase::class); + +it('supports invoice CRUD operations', function () { + // Create + $invoice = Invoice::factory()->create([ + 'status' => Status::Draft, + 'amount' => '100.00', + ]); + + expect($invoice->exists)->toBeTrue(); + $this->assertDatabaseHas('invoices', ['id' => $invoice->id, 'amount' => '100.00']); + + // Read + cast/helper checks + $fresh = Invoice::query()->findOrFail($invoice->id); + expect($fresh->status)->toBe(Status::Draft) + ->and($fresh->isDraft())->toBeTrue(); + + // Update + $fresh->update(['amount' => '250.00']); + $this->assertDatabaseHas('invoices', ['id' => $fresh->id, 'amount' => '250.00']); + + // Delete (supports soft deletes) + $fresh->delete(); + $this->assertSoftDeleted('invoices', ['id' => $fresh->id]); + // For hard-deleting models, use instead: + // $this->assertDatabaseMissing('invoices', ['id' => $fresh->id]); +}); + +it('defines and resolves invoiceLines relation', function () { + $invoice = Invoice::factory()->create(); + + // Relation shape + expect($invoice->invoiceLines())->toBeInstanceOf(HasMany::class); + + // Relation behavior + InvoiceLine::factory()->count(2)->create(['invoice_id' => $invoice->id]); + + expect($invoice->invoiceLines)->toHaveCount(2) + ->and($invoice->invoiceLines->first())->toBeInstanceOf(InvoiceLine::class); +}); +``` + ## Checklists ### Execution Checklist @@ -126,7 +216,12 @@ class Invoice extends Model - [ ] `LogsActivity` trait is added where auditing is required. - [ ] `getActivitylogOptions()` is configured with `logAll()`, `logOnlyDirty()`, and `dontSubmitEmptyLogs()`. - [ ] All relationship methods have correct typed return types. +- [ ] Relationship method names use `camelCase(RelatedModelName)` with correct singular/plural form. +- [ ] Existing generic relation names are renamed to explicit model-based names (for example, `steps()` -> `pipelineSteps()`). - [ ] A matching factory exists in `database/factories/`. +- [ ] A model test exists and covers **Create, Read, Update, Delete** behavior. +- [ ] Every relationship method has at least one assertion for relation type and one for relation data retrieval. +- [ ] At least one cast/helper assertion validates domain behavior (for example enum or status helper). - [ ] Business logic is extracted to Actions or Services instead of living directly in the model. ## Safety / Things to Avoid @@ -135,7 +230,10 @@ class Invoice extends Model - Defining `$casts` as a property instead of a `casts()` method. - Omitting the `LogsActivity` trait on business models that should be audited. - Omitting return types on relationship methods. +- Using ambiguous relationship names that do not reflect the related model class. - Creating a model without a corresponding factory. +- Creating or updating a model without adding/updating CRUD + relation tests. +- Testing only relation existence but not relation behavior (or vice versa). - Putting complex business logic directly in the model — prefer Actions or Services. - Defining model shape with `protected array $fillable = ['name'];` and `protected array $casts = ['status' => 'string'];` instead of `$guarded = []` and `casts()` @@ -145,3 +243,5 @@ class Invoice extends Model - [Spatie Activity Log](https://spatie.be/docs/laravel-activitylog/) - Related: `Enums/SKILL.md` — enums are cast in `casts()` - Related: `Migrations/SKILL.md` — migrations define the model's schema +- Related: `PestTesting/SKILL.md` — preferred style for model tests +- Related: `PHPUnit/SKILL.md` — class-based alternative where required