From 9a4c6b5d8c9b8e94adb4ad6497ab65610a65ae11 Mon Sep 17 00:00:00 2001 From: Evren Ceyhan Date: Sun, 6 Jul 2025 14:42:03 +0200 Subject: [PATCH 01/11] Add autoloading for Database\Factories\States namespace --- composer.json | 1 + 1 file changed, 1 insertion(+) diff --git a/composer.json b/composer.json index 1516d41..5812e12 100644 --- a/composer.json +++ b/composer.json @@ -31,6 +31,7 @@ "autoload": { "psr-4": { "App\\": "app/", + "Database\\Factories\\States\\": "database/factories/states/", "Database\\Factories\\": "database/factories/", "Database\\Seeders\\": "database/seeders/" } From 0abd41f0c727a682e8e972400284514fdf465c34 Mon Sep 17 00:00:00 2001 From: Evren Ceyhan Date: Sun, 6 Jul 2025 14:56:54 +0200 Subject: [PATCH 02/11] Implement HasProgress concern for DeviceStatus, InvoiceStatus, OrderStatus, TaskStatus, and TicketStatus enums --- app/Enums/Concerns/HasProgress.php | 44 +++++++++++++ app/Enums/DeviceStatus.php | 22 ++++++- app/Enums/InvoiceStatus.php | 22 ++++++- app/Enums/OrderStatus.php | 20 +++++- app/Enums/TaskStatus.php | 19 +++++- app/Enums/TicketStatus.php | 21 ++++++- app/Models/Concerns/HasProgress.php | 63 +++++++++++++++++++ app/Models/Device.php | 3 +- app/Models/Invoice.php | 18 ++---- app/Models/Order.php | 3 +- app/Models/Task.php | 3 +- app/Models/Ticket.php | 12 ++-- database/factories/DeviceFactory.php | 10 ++- database/factories/InvoiceFactory.php | 3 + database/factories/OrderFactory.php | 3 + database/factories/TaskFactory.php | 3 + database/factories/TicketFactory.php | 3 + .../factories/states/HasProgressStates.php | 30 +++++++++ .../Unit/Models/Concerns/HasProgressTest.php | 60 ++++++++++++++++++ 19 files changed, 331 insertions(+), 31 deletions(-) create mode 100644 app/Enums/Concerns/HasProgress.php create mode 100644 app/Models/Concerns/HasProgress.php create mode 100644 database/factories/states/HasProgressStates.php create mode 100644 tests/Unit/Models/Concerns/HasProgressTest.php diff --git a/app/Enums/Concerns/HasProgress.php b/app/Enums/Concerns/HasProgress.php new file mode 100644 index 0000000..1827024 --- /dev/null +++ b/app/Enums/Concerns/HasProgress.php @@ -0,0 +1,44 @@ + + */ + abstract public static function pendingCases(): array; + + /** + * Get list of complete enum cases. + * + * @return list + */ + abstract public static function completeCases(): array; + + /** + * Determine if the enum case is pending. + */ + public function isPending(): bool + { + return in_array($this, static::pendingCases()); + } + + /** + * Determine if the enum case is void (excluded from progress calculations). + */ + public function isVoid(): bool + { + return ! $this->isPending() && ! $this->isComplete(); + } + + /** + * Determine if the enum case is complete. + */ + public function isComplete(): bool + { + return in_array($this, static::completeCases()); + } +} diff --git a/app/Enums/DeviceStatus.php b/app/Enums/DeviceStatus.php index b0295c7..8941b0f 100644 --- a/app/Enums/DeviceStatus.php +++ b/app/Enums/DeviceStatus.php @@ -2,11 +2,12 @@ namespace App\Enums; +use App\Enums\Concerns\HasProgress; use App\Enums\Concerns\HasValues; enum DeviceStatus: string { - use HasValues; + use HasProgress, HasValues; /** * Device has been received and is awaiting repair. @@ -32,4 +33,23 @@ enum DeviceStatus: string * Device has been delivered to the customer. */ case Delivered = 'delivered'; + + // METHODS ///////////////////////////////////////////////////////////////////////////////////// + + public static function pendingCases(): array + { + return [ + self::Received, + self::OnHold, + self::UnderRepair, + ]; + } + + public static function completeCases(): array + { + return [ + self::Ready, + self::Delivered, + ]; + } } diff --git a/app/Enums/InvoiceStatus.php b/app/Enums/InvoiceStatus.php index ceeb6e2..171f3e6 100644 --- a/app/Enums/InvoiceStatus.php +++ b/app/Enums/InvoiceStatus.php @@ -2,11 +2,12 @@ namespace App\Enums; +use App\Enums\Concerns\HasProgress; use App\Enums\Concerns\HasValues; enum InvoiceStatus: string { - use HasValues; + use HasProgress, HasValues; /** * Invoice has been created and is awaiting approval. @@ -37,4 +38,23 @@ enum InvoiceStatus: string * Invoice has been cancelled. */ case Cancelled = 'cancelled'; + + // METHODS ///////////////////////////////////////////////////////////////////////////////////// + + public static function pendingCases(): array + { + return [ + self::Draft, + self::Issued, + self::Sent, + ]; + } + + public static function completeCases(): array + { + return [ + self::Paid, + self::Refunded, + ]; + } } diff --git a/app/Enums/OrderStatus.php b/app/Enums/OrderStatus.php index 53ee652..a617a6b 100644 --- a/app/Enums/OrderStatus.php +++ b/app/Enums/OrderStatus.php @@ -2,11 +2,12 @@ namespace App\Enums; +use App\Enums\Concerns\HasProgress; use App\Enums\Concerns\HasValues; enum OrderStatus: string { - use HasValues; + use HasProgress, HasValues; /** * Represents an order that has been created and is awaiting processing. @@ -29,4 +30,21 @@ enum OrderStatus: string * Represents an order that has been cancelled by the customer or by the system. */ case Cancelled = 'cancelled'; + + // METHODS ///////////////////////////////////////////////////////////////////////////////////// + + public static function pendingCases(): array + { + return [ + self::New, + self::Shipped, + ]; + } + + public static function completeCases(): array + { + return [ + self::Received, + ]; + } } diff --git a/app/Enums/TaskStatus.php b/app/Enums/TaskStatus.php index 53f04c1..7e7c72b 100644 --- a/app/Enums/TaskStatus.php +++ b/app/Enums/TaskStatus.php @@ -2,11 +2,12 @@ namespace App\Enums; +use App\Enums\Concerns\HasProgress; use App\Enums\Concerns\HasValues; enum TaskStatus: string { - use HasValues; + use HasProgress, HasValues; /** * Represents an task that has been created and is pending approval. @@ -24,4 +25,20 @@ enum TaskStatus: string * Represents an task that has been cancelled. */ case Cancelled = 'cancelled'; + + // METHODS ///////////////////////////////////////////////////////////////////////////////////// + + public static function pendingCases(): array + { + return [ + self::New, + ]; + } + + public static function completeCases(): array + { + return [ + self::Completed, + ]; + } } diff --git a/app/Enums/TicketStatus.php b/app/Enums/TicketStatus.php index 90d908d..35b3489 100644 --- a/app/Enums/TicketStatus.php +++ b/app/Enums/TicketStatus.php @@ -2,11 +2,12 @@ namespace App\Enums; +use App\Enums\Concerns\HasProgress; use App\Enums\Concerns\HasValues; enum TicketStatus: string { - use HasValues; + use HasProgress, HasValues; /** * The ticket is new and has not been assigned to anyone. @@ -35,4 +36,22 @@ enum TicketStatus: string */ case Closed = 'closed'; + // METHODS ///////////////////////////////////////////////////////////////////////////////////// + + public static function pendingCases(): array + { + return [ + self::New, + self::InProgress, + self::OnHold, + ]; + } + + public static function completeCases(): array + { + return [ + self::Resolved, + self::Closed, + ]; + } } diff --git a/app/Models/Concerns/HasProgress.php b/app/Models/Concerns/HasProgress.php new file mode 100644 index 0000000..4a49c08 --- /dev/null +++ b/app/Models/Concerns/HasProgress.php @@ -0,0 +1,63 @@ +{static::STATUS}->isPending(); + } + + /** + * Determine if the model status is void (excluded from progress calculations). + */ + public function isVoid(): bool + { + return $this->{static::STATUS}->isVoid(); + } + + /** + * Determine if the model status is complete. + */ + public function isComplete(): bool + { + return $this->{static::STATUS}->isComplete(); + } + + // SCOPES ////////////////////////////////////////////////////////////////////////////////////// + + /** + * Scope a query to only include pending models. + */ + public function scopePending(Builder $query): void + { + $query->whereIn(static::STATUS, $this->{static::STATUS}::pendingCases()); + } + + /** + * Scope a query to only include void models. + */ + public function scopeVoid(Builder $query): void + { + $query->whereNotIn(static::STATUS, [ + ...$this->{static::STATUS}::pendingCases(), + ...$this->{static::STATUS}::completeCases(), + ]); + } + + /** + * Scope a query to only include complete models. + */ + public function scopeComplete(Builder $query): void + { + $query->whereIn(static::STATUS, $this->{static::STATUS}::completeCases()); + } +} diff --git a/app/Models/Device.php b/app/Models/Device.php index 965404d..071dfd9 100644 --- a/app/Models/Device.php +++ b/app/Models/Device.php @@ -4,6 +4,7 @@ use App\Enums\DeviceStatus; use App\Enums\DeviceType; +use App\Models\Concerns\HasProgress; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -13,7 +14,7 @@ class Device extends Model { /** @use HasFactory<\Database\Factories\DeviceFactory> */ - use HasFactory; + use HasFactory, HasProgress; /** * The model's default values for attributes. diff --git a/app/Models/Invoice.php b/app/Models/Invoice.php index d06d395..287ab6c 100644 --- a/app/Models/Invoice.php +++ b/app/Models/Invoice.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Enums\InvoiceStatus; +use App\Models\Concerns\HasProgress; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -12,7 +13,7 @@ class Invoice extends Model { /** @use HasFactory<\Database\Factories\InvoiceFactory> */ - use HasFactory; + use HasFactory, HasProgress; /** * The model's default values for attributes. @@ -102,25 +103,14 @@ public function scopeOfStatus(Builder $query, InvoiceStatus $status): void */ public function scopeOverdue(Builder $query): void { - $query - ->where('due_date', '<', now()) - ->whereIn('status', [ - InvoiceStatus::Issued->value, - InvoiceStatus::Sent->value, - ]); + $query->where('due_date', '<', now())->pending(); } - // METHODS ///////////////////////////////////////////////////////////////////////////////////// - /** * Determine if the invoice is overdue. */ public function isOverdue(): bool { - return $this->due_date->isPast() - && in_array($this->status, [ - InvoiceStatus::Issued, - InvoiceStatus::Sent, - ]); + return $this->due_date->isPast() && $this->isPending(); } } diff --git a/app/Models/Order.php b/app/Models/Order.php index d63cedf..365c983 100644 --- a/app/Models/Order.php +++ b/app/Models/Order.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Enums\OrderStatus; +use App\Models\Concerns\HasProgress; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -10,7 +11,7 @@ class Order extends Model { /** @use HasFactory<\Database\Factories\OrderFactory> */ - use HasFactory; + use HasFactory, HasProgress; /** * The model's default values for attributes. diff --git a/app/Models/Task.php b/app/Models/Task.php index 73e8a86..26fe43d 100644 --- a/app/Models/Task.php +++ b/app/Models/Task.php @@ -4,6 +4,7 @@ use App\Enums\TaskStatus; use App\Enums\TaskType; +use App\Models\Concerns\HasProgress; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -11,7 +12,7 @@ class Task extends Model { /** @use HasFactory<\Database\Factories\TaskFactory> */ - use HasFactory; + use HasFactory, HasProgress; /** * The model's default values for attributes. diff --git a/app/Models/Ticket.php b/app/Models/Ticket.php index ab8a742..e1f3ffa 100644 --- a/app/Models/Ticket.php +++ b/app/Models/Ticket.php @@ -4,6 +4,7 @@ use App\Enums\TicketPriority; use App\Enums\TicketStatus; +use App\Models\Concerns\HasProgress; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -14,7 +15,7 @@ class Ticket extends Model { /** @use HasFactory<\Database\Factories\TicketFactory> */ - use HasFactory; + use HasFactory, HasProgress; /** * The model's default values for attributes. @@ -131,13 +132,9 @@ public function scopeOfStatus(Builder $query, TicketStatus $status): void */ public function scopeOverdue(Builder $query): void { - $query - ->where('due_date', '<', now()) - ->whereNot('status', TicketStatus::Closed->value); + $query->where('due_date', '<', now())->pending(); } - // METHODS ///////////////////////////////////////////////////////////////////////////////////// - /** * Determine if the ticket is assignable to a user. */ @@ -167,7 +164,6 @@ public function unassign(): void */ public function isOverdue(): bool { - return $this->due_date->isPast() - && $this->status !== TicketStatus::Closed; + return $this->due_date->isPast() && $this->isPending(); } } diff --git a/database/factories/DeviceFactory.php b/database/factories/DeviceFactory.php index 4814e8c..16000ee 100644 --- a/database/factories/DeviceFactory.php +++ b/database/factories/DeviceFactory.php @@ -5,6 +5,7 @@ use App\Enums\DeviceStatus; use App\Enums\DeviceType; use App\Models\Customer; +use Database\Factories\States\HasProgressStates; use Illuminate\Database\Eloquent\Factories\Factory; /** @@ -14,6 +15,8 @@ */ class DeviceFactory extends Factory { + use HasProgressStates; + const VERSIONS = [ 'iMac' => ['21"', '27"'], 'Mac' => ['Mini', 'Pro', 'Studio'], @@ -68,6 +71,11 @@ class DeviceFactory extends Factory 'Go Comfort' => DeviceType::Other, ]; + /** + * Define the model's default state. + * + * @return array + */ public function definition(): array { // Generate random model name and version @@ -82,7 +90,7 @@ public function definition(): array 'purchase_date' => now()->subYears(1), 'warranty_expire_date' => now()->addYears(1), 'type' => self::TYPES[$name], - 'status' => fake()->randomElement(DeviceStatus::values()), + 'status' => DeviceStatus::Received, ]; } diff --git a/database/factories/InvoiceFactory.php b/database/factories/InvoiceFactory.php index b991a2f..de1ae93 100644 --- a/database/factories/InvoiceFactory.php +++ b/database/factories/InvoiceFactory.php @@ -4,6 +4,7 @@ use App\Enums\InvoiceStatus; use App\Models\Ticket; +use Database\Factories\States\HasProgressStates; use Illuminate\Database\Eloquent\Factories\Factory; /** @@ -13,6 +14,8 @@ */ class InvoiceFactory extends Factory { + use HasProgressStates; + /** * Define the model's default state. * diff --git a/database/factories/OrderFactory.php b/database/factories/OrderFactory.php index dbee247..bce58dc 100644 --- a/database/factories/OrderFactory.php +++ b/database/factories/OrderFactory.php @@ -4,6 +4,7 @@ use App\Enums\OrderStatus; use App\Models\Ticket; +use Database\Factories\States\HasProgressStates; use Illuminate\Database\Eloquent\Factories\Factory; /** @@ -11,6 +12,8 @@ */ class OrderFactory extends Factory { + use HasProgressStates; + const PARTS = [ 'Dell XPS 13 Battery', 'MacBook Pro Retina Screen', diff --git a/database/factories/TaskFactory.php b/database/factories/TaskFactory.php index d18ff37..f34cc57 100644 --- a/database/factories/TaskFactory.php +++ b/database/factories/TaskFactory.php @@ -5,6 +5,7 @@ use App\Enums\TaskStatus; use App\Enums\TaskType; use App\Models\Ticket; +use Database\Factories\States\HasProgressStates; use Illuminate\Database\Eloquent\Factories\Factory; /** @@ -12,6 +13,8 @@ */ class TaskFactory extends Factory { + use HasProgressStates; + /** * Define the model's default state. * diff --git a/database/factories/TicketFactory.php b/database/factories/TicketFactory.php index 84373af..2fbad68 100644 --- a/database/factories/TicketFactory.php +++ b/database/factories/TicketFactory.php @@ -6,6 +6,7 @@ use App\Enums\TicketStatus; use App\Models\Device; use App\Models\User; +use Database\Factories\States\HasProgressStates; use Illuminate\Database\Eloquent\Factories\Factory; /** @@ -16,6 +17,8 @@ */ class TicketFactory extends Factory { + use HasProgressStates; + /** * Define the model's default state. * diff --git a/database/factories/states/HasProgressStates.php b/database/factories/states/HasProgressStates.php new file mode 100644 index 0000000..83dd787 --- /dev/null +++ b/database/factories/states/HasProgressStates.php @@ -0,0 +1,30 @@ +state(fn (array $attributes) => [ + static::STATUS => Arr::first($attributes[static::STATUS]::pendingCases()), + ]); + } + + /** + * Indicate that the model is complete. + */ + public function complete(): self + { + return $this->state(fn (array $attributes) => [ + static::STATUS => Arr::first($attributes[static::STATUS]::completeCases()), + ]); + } +} diff --git a/tests/Unit/Models/Concerns/HasProgressTest.php b/tests/Unit/Models/Concerns/HasProgressTest.php new file mode 100644 index 0000000..72d3e92 --- /dev/null +++ b/tests/Unit/Models/Concerns/HasProgressTest.php @@ -0,0 +1,60 @@ + [Device::class], + 'Ticket' => [Ticket::class], + 'Task' => [Task::class], + 'Order' => [Order::class], + 'Invoice' => [Invoice::class], +]); + +it('can determine if model is pending', function (string $modelClass) { + // Arrange + $model = $modelClass::factory()->pending()->create(); + + // Assert + expect($model->isPending())->toBeTrue(); +})->with('models'); + +it('can determine if model is complete', function (string $modelClass) { + // Arrange + $model = $modelClass::factory()->complete()->create(); + + // Assert + expect($model->isComplete())->toBeTrue(); +})->with('models'); + +it('can filter records by pending scope', function (string $modelClass) { + // Arrange + $modelClass::factory(2)->pending()->create(); + $modelClass::factory(1)->complete()->create(); + + // Act + $pendingModels = $modelClass::query()->pending()->get(); + + // Assert + expect($pendingModels)->toHaveCount(2); + expect($pendingModels->first()->isPending())->toBeTrue(); +})->with('models'); + +it('can filter records by complete scope', function (string $modelClass) { + // Arrange + $modelClass::factory(2)->complete()->create(); + $modelClass::factory(1)->pending()->create(); + + // Act + $completeModels = $modelClass::query()->complete()->get(); + + // Assert + expect($completeModels)->toHaveCount(2); + expect($completeModels->first()->isComplete())->toBeTrue(); +})->with('models'); From 46a040884208bc748393b157c620dd37bd9214b9 Mon Sep 17 00:00:00 2001 From: Evren Ceyhan Date: Sat, 5 Jul 2025 00:32:47 +0200 Subject: [PATCH 03/11] Implement Billable concern for Order and Task models --- app/Models/Concerns/Billable.php | 46 +++++++++++++++ app/Models/Order.php | 13 +---- app/Models/Task.php | 13 +---- database/factories/OrderFactory.php | 13 +---- database/factories/TaskFactory.php | 13 +---- .../factories/states/HasBillableStates.php | 28 ++++++++++ tests/Unit/Models/Concerns/BillableTest.php | 56 +++++++++++++++++++ tests/Unit/Models/OrderTest.php | 22 -------- tests/Unit/Models/TaskTest.php | 23 +------- 9 files changed, 139 insertions(+), 88 deletions(-) create mode 100644 app/Models/Concerns/Billable.php create mode 100644 database/factories/states/HasBillableStates.php create mode 100644 tests/Unit/Models/Concerns/BillableTest.php diff --git a/app/Models/Concerns/Billable.php b/app/Models/Concerns/Billable.php new file mode 100644 index 0000000..7cf016f --- /dev/null +++ b/app/Models/Concerns/Billable.php @@ -0,0 +1,46 @@ +casts[static::IS_BILLABLE] = 'boolean'; + + $this->attributes[static::IS_BILLABLE] = true; + } + + /** + * Determine if the model is billable. + */ + public function isBillable(): bool + { + return (bool) $this->{static::IS_BILLABLE}; + } + + // SCOPES ////////////////////////////////////////////////////////////////////////////////////// + + /** + * Scope a query to only include billable models. + */ + public function scopeBillable(Builder $query): void + { + $query->where(static::IS_BILLABLE, true); + } + + /** + * Scope a query to only include models that are not billable. + */ + public function scopeNotBillable(Builder $query): void + { + $query->where(static::IS_BILLABLE, false); + } +} diff --git a/app/Models/Order.php b/app/Models/Order.php index 365c983..5380bbe 100644 --- a/app/Models/Order.php +++ b/app/Models/Order.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Enums\OrderStatus; +use App\Models\Concerns\Billable; use App\Models\Concerns\HasProgress; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -11,7 +12,7 @@ class Order extends Model { /** @use HasFactory<\Database\Factories\OrderFactory> */ - use HasFactory, HasProgress; + use Billable, HasFactory, HasProgress; /** * The model's default values for attributes. @@ -20,7 +21,6 @@ class Order extends Model */ protected $attributes = [ 'quantity' => 1, - 'is_billable' => true, 'status' => OrderStatus::New, ]; @@ -48,7 +48,6 @@ class Order extends Model protected $casts = [ 'quantity' => 'integer', 'cost' => 'float', - 'is_billable' => 'boolean', 'status' => OrderStatus::class, 'approved_at' => 'datetime', ]; @@ -65,14 +64,6 @@ public function ticket() // SCOPES ////////////////////////////////////////////////////////////////////////////////////// - /** - * Scope a query to only include orders that are billable. - */ - public function scopeBillable(Builder $query): void - { - $query->where('is_billable', true); - } - /** * Scope a query to only include orders of a given status. */ diff --git a/app/Models/Task.php b/app/Models/Task.php index 26fe43d..8a49959 100644 --- a/app/Models/Task.php +++ b/app/Models/Task.php @@ -4,6 +4,7 @@ use App\Enums\TaskStatus; use App\Enums\TaskType; +use App\Models\Concerns\Billable; use App\Models\Concerns\HasProgress; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -12,7 +13,7 @@ class Task extends Model { /** @use HasFactory<\Database\Factories\TaskFactory> */ - use HasFactory, HasProgress; + use Billable, HasFactory, HasProgress; /** * The model's default values for attributes. @@ -20,7 +21,6 @@ class Task extends Model * @var array */ protected $attributes = [ - 'is_billable' => true, 'type' => TaskType::Repair, 'status' => TaskStatus::New, ]; @@ -46,7 +46,6 @@ class Task extends Model */ protected $casts = [ 'cost' => 'float', - 'is_billable' => 'boolean', 'type' => TaskType::class, 'status' => TaskStatus::class, 'approved_at' => 'datetime', @@ -64,14 +63,6 @@ public function ticket() // SCOPES ////////////////////////////////////////////////////////////////////////////////////// - /** - * Scope a query to only include tasks that are billable. - */ - public function scopeBillable(Builder $query): void - { - $query->where('is_billable', true); - } - /** * Scope a query to only include tasks of a given type. */ diff --git a/database/factories/OrderFactory.php b/database/factories/OrderFactory.php index bce58dc..e618b9d 100644 --- a/database/factories/OrderFactory.php +++ b/database/factories/OrderFactory.php @@ -4,6 +4,7 @@ use App\Enums\OrderStatus; use App\Models\Ticket; +use Database\Factories\States\HasBillableStates; use Database\Factories\States\HasProgressStates; use Illuminate\Database\Eloquent\Factories\Factory; @@ -12,7 +13,7 @@ */ class OrderFactory extends Factory { - use HasProgressStates; + use HasBillableStates, HasProgressStates; const PARTS = [ 'Dell XPS 13 Battery', @@ -70,16 +71,6 @@ public function forTicket(Ticket $ticket): self // STATES ////////////////////////////////////////////////////////////////////////////////////// - /** - * Indicate that the order is non-billable. - */ - public function nonBillable(): self - { - return $this->state(fn (array $attributes) => [ - 'is_billable' => false, - ]); - } - /** * Indicate that the order is of a specified status. */ diff --git a/database/factories/TaskFactory.php b/database/factories/TaskFactory.php index f34cc57..5574c36 100644 --- a/database/factories/TaskFactory.php +++ b/database/factories/TaskFactory.php @@ -5,6 +5,7 @@ use App\Enums\TaskStatus; use App\Enums\TaskType; use App\Models\Ticket; +use Database\Factories\States\HasBillableStates; use Database\Factories\States\HasProgressStates; use Illuminate\Database\Eloquent\Factories\Factory; @@ -13,7 +14,7 @@ */ class TaskFactory extends Factory { - use HasProgressStates; + use HasBillableStates, HasProgressStates; /** * Define the model's default state. @@ -48,16 +49,6 @@ public function forTicket(Ticket $ticket): self // STATES ////////////////////////////////////////////////////////////////////////////////////// - /** - * Indicate that the task is non-billable. - */ - public function nonBillable(): self - { - return $this->state(fn (array $attributes) => [ - 'is_billable' => false, - ]); - } - /** * Indicate that the task is of a specific type. */ diff --git a/database/factories/states/HasBillableStates.php b/database/factories/states/HasBillableStates.php new file mode 100644 index 0000000..1f17ed1 --- /dev/null +++ b/database/factories/states/HasBillableStates.php @@ -0,0 +1,28 @@ +state(fn (array $attributes) => [ + static::IS_BILLABLE => true, + ]); + } + + /** + * Indicate that the model is not billable. + */ + public function notBillable(): self + { + return $this->state(fn (array $attributes) => [ + static::IS_BILLABLE => false, + ]); + } +} diff --git a/tests/Unit/Models/Concerns/BillableTest.php b/tests/Unit/Models/Concerns/BillableTest.php new file mode 100644 index 0000000..b1e5443 --- /dev/null +++ b/tests/Unit/Models/Concerns/BillableTest.php @@ -0,0 +1,56 @@ + [Order::class], + 'Task' => [Task::class], +]); + +it('initializes model properties correctly', function (string $modelClass) { + // Arrange + $model = new $modelClass; + + // Assert + expect($model->getCasts())->toHaveKey('is_billable', 'boolean'); + expect($model->getAttributes())->toHaveKey('is_billable', true); +})->with('models'); + +it('can determine if model is billable', function (string $modelClass) { + // Arrange + $model = $modelClass::factory()->billable()->create(); + + // Assert + expect($model->isBillable())->toBeTrue(); + expect($model->is_billable)->toBeTrue(); +})->with('models'); + +it('can filter records by billable scope', function (string $modelClass) { + // Arrange + $modelClass::factory(2)->billable()->create(); + $modelClass::factory(1)->notBillable()->create(); + + // Act + $billableModels = $modelClass::query()->billable()->get(); + + // Assert + expect($billableModels)->toHaveCount(2); + expect($billableModels->first()->isBillable())->toBeTrue(); +})->with('models'); + +it('can filter records by not billable scope', function (string $modelClass) { + // Arrange + $modelClass::factory(2)->notBillable()->create(); + $modelClass::factory(1)->billable()->create(); + + // Act + $notBillableModels = $modelClass::query()->notBillable()->get(); + + // Assert + expect($notBillableModels)->toHaveCount(2); + expect($notBillableModels->first()->isBillable())->toBeFalse(); +})->with('models'); diff --git a/tests/Unit/Models/OrderTest.php b/tests/Unit/Models/OrderTest.php index 0ca5152..a5f6a5e 100644 --- a/tests/Unit/Models/OrderTest.php +++ b/tests/Unit/Models/OrderTest.php @@ -89,28 +89,6 @@ expect($order->ticket->id)->toBe($ticket->id); }); -it('can determine if an order is billable', function () { - // Arrange & Act & Assert - $order = Order::factory()->make(['is_billable' => true]); - expect($order->is_billable)->toBeTrue(); - - $order = Order::factory()->make(['is_billable' => false]); - expect($order->is_billable)->toBeFalse(); -}); - -it('can filter orders by billable scope', function () { - // Arrange - Order::factory()->create(); - Order::factory()->nonBillable()->create(); - - // Act - $orders = Order::query()->billable()->get(); - - // Assert - expect($orders)->toHaveCount(1); - expect($orders->first()->is_billable)->toBeTrue(); -}); - it('can filter orders by status scope', function (OrderStatus $status) { // Arrange Order::factory()->ofStatus($status)->create(); diff --git a/tests/Unit/Models/TaskTest.php b/tests/Unit/Models/TaskTest.php index 54acd7e..95a635a 100644 --- a/tests/Unit/Models/TaskTest.php +++ b/tests/Unit/Models/TaskTest.php @@ -16,6 +16,7 @@ expect($task->ticket_id)->not->toBeNull(); expect($task->description)->not->toBeEmpty(); expect($task->cost)->toBeGreaterThan(0); + expect($task->is_billable)->toBeTrue(); expect($task->status)->toBeInstanceOf(TaskStatus::class); expect($task->type)->toBeInstanceOf(TaskType::class); }); @@ -87,28 +88,6 @@ expect($task->ticket->id)->toBe($ticket->id); }); -it('can determine if a task is billable', function () { - // Arrange & Act & Assert - $task = Task::factory()->make(['is_billable' => true]); - expect($task->is_billable)->toBeTrue(); - - $task = Task::factory()->make(['is_billable' => false]); - expect($task->is_billable)->toBeFalse(); -}); - -it('can filter tasks by billable scope', function () { - // Arrange - Task::factory()->create(); - Task::factory()->nonBillable()->create(); - - // Act - $tasks = Task::query()->billable()->get(); - - // Assert - expect($tasks)->toHaveCount(1); - expect($tasks->first()->is_billable)->toBeTrue(); -}); - it('can filter tasks by type scope', function (TaskType $type) { // Arrange Task::factory()->ofType($type)->create(); From 0cd9128d77380b9c6180dbcbd74338785401419f Mon Sep 17 00:00:00 2001 From: Evren Ceyhan Date: Sat, 5 Jul 2025 01:21:33 +0200 Subject: [PATCH 04/11] Implement HasApproval concern for Order and Task models --- app/Models/Concerns/HasApproval.php | 44 +++++++++++++++ app/Models/Order.php | 12 +--- app/Models/Task.php | 12 +--- database/factories/OrderFactory.php | 13 +---- database/factories/TaskFactory.php | 13 +---- .../factories/states/HasApprovalStates.php | 28 ++++++++++ database/seeders/OrderSeeder.php | 2 +- database/seeders/TaskSeeder.php | 2 +- .../Unit/Models/Concerns/HasApprovalTest.php | 55 +++++++++++++++++++ tests/Unit/Models/OrderTest.php | 12 ---- tests/Unit/Models/TaskTest.php | 16 ++---- 11 files changed, 141 insertions(+), 68 deletions(-) create mode 100644 app/Models/Concerns/HasApproval.php create mode 100644 database/factories/states/HasApprovalStates.php create mode 100644 tests/Unit/Models/Concerns/HasApprovalTest.php diff --git a/app/Models/Concerns/HasApproval.php b/app/Models/Concerns/HasApproval.php new file mode 100644 index 0000000..75ac06c --- /dev/null +++ b/app/Models/Concerns/HasApproval.php @@ -0,0 +1,44 @@ +casts[static::APPROVED_AT] = 'datetime'; + } + + /** + * Determine if the model is approved. + */ + public function isApproved(): bool + { + return $this->{static::APPROVED_AT} !== null; + } + + // SCOPES ////////////////////////////////////////////////////////////////////////////////////// + + /** + * Scope a query to only include approved models. + */ + public function scopeApproved(Builder $query): void + { + $query->whereNotNull(static::APPROVED_AT); + } + + /** + * Scope a query to only include unapproved models. + */ + public function scopeUnapproved(Builder $query): void + { + $query->whereNull(static::APPROVED_AT); + } +} diff --git a/app/Models/Order.php b/app/Models/Order.php index 5380bbe..b7fa20b 100644 --- a/app/Models/Order.php +++ b/app/Models/Order.php @@ -4,6 +4,7 @@ use App\Enums\OrderStatus; use App\Models\Concerns\Billable; +use App\Models\Concerns\HasApproval; use App\Models\Concerns\HasProgress; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -12,7 +13,7 @@ class Order extends Model { /** @use HasFactory<\Database\Factories\OrderFactory> */ - use Billable, HasFactory, HasProgress; + use Billable, HasApproval, HasFactory, HasProgress; /** * The model's default values for attributes. @@ -49,7 +50,6 @@ class Order extends Model 'quantity' => 'integer', 'cost' => 'float', 'status' => OrderStatus::class, - 'approved_at' => 'datetime', ]; // RELATIONS /////////////////////////////////////////////////////////////////////////////////// @@ -71,12 +71,4 @@ public function scopeOfStatus(Builder $query, OrderStatus $status): void { $query->where('status', $status->value); } - - /** - * Scope a query to only include approved orders. - */ - public function scopeApproved(Builder $query): void - { - $query->whereNotNull('approved_at'); - } } diff --git a/app/Models/Task.php b/app/Models/Task.php index 8a49959..ad0cdb6 100644 --- a/app/Models/Task.php +++ b/app/Models/Task.php @@ -5,6 +5,7 @@ use App\Enums\TaskStatus; use App\Enums\TaskType; use App\Models\Concerns\Billable; +use App\Models\Concerns\HasApproval; use App\Models\Concerns\HasProgress; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -13,7 +14,7 @@ class Task extends Model { /** @use HasFactory<\Database\Factories\TaskFactory> */ - use Billable, HasFactory, HasProgress; + use Billable, HasApproval, HasFactory, HasProgress; /** * The model's default values for attributes. @@ -48,7 +49,6 @@ class Task extends Model 'cost' => 'float', 'type' => TaskType::class, 'status' => TaskStatus::class, - 'approved_at' => 'datetime', ]; // RELATIONS /////////////////////////////////////////////////////////////////////////////////// @@ -78,12 +78,4 @@ public function scopeOfStatus(Builder $query, TaskStatus $status): void { $query->where('status', $status->value); } - - /** - * Scope a query to only include approved tasks. - */ - public function scopeApproved(Builder $query): void - { - $query->whereNotNull('approved_at'); - } } diff --git a/database/factories/OrderFactory.php b/database/factories/OrderFactory.php index e618b9d..fc35765 100644 --- a/database/factories/OrderFactory.php +++ b/database/factories/OrderFactory.php @@ -4,6 +4,7 @@ use App\Enums\OrderStatus; use App\Models\Ticket; +use Database\Factories\States\HasApprovalStates; use Database\Factories\States\HasBillableStates; use Database\Factories\States\HasProgressStates; use Illuminate\Database\Eloquent\Factories\Factory; @@ -13,7 +14,7 @@ */ class OrderFactory extends Factory { - use HasBillableStates, HasProgressStates; + use HasApprovalStates, HasBillableStates, HasProgressStates; const PARTS = [ 'Dell XPS 13 Battery', @@ -80,14 +81,4 @@ public function ofStatus(OrderStatus $status): self 'status' => $status, ]); } - - /** - * Indicate that the order needs customer approval. - */ - public function needsApproval(): self - { - return $this->state(fn (array $attributes) => [ - 'approved_at' => null, - ]); - } } diff --git a/database/factories/TaskFactory.php b/database/factories/TaskFactory.php index 5574c36..fc780db 100644 --- a/database/factories/TaskFactory.php +++ b/database/factories/TaskFactory.php @@ -5,6 +5,7 @@ use App\Enums\TaskStatus; use App\Enums\TaskType; use App\Models\Ticket; +use Database\Factories\States\HasApprovalStates; use Database\Factories\States\HasBillableStates; use Database\Factories\States\HasProgressStates; use Illuminate\Database\Eloquent\Factories\Factory; @@ -14,7 +15,7 @@ */ class TaskFactory extends Factory { - use HasBillableStates, HasProgressStates; + use HasApprovalStates, HasBillableStates, HasProgressStates; /** * Define the model's default state. @@ -68,14 +69,4 @@ public function ofStatus(TaskStatus $status): self 'status' => $status, ]); } - - /** - * Indicate that the task needs customer approval. - */ - public function needsApproval(): self - { - return $this->state(fn (array $attributes) => [ - 'approved_at' => null, - ]); - } } diff --git a/database/factories/states/HasApprovalStates.php b/database/factories/states/HasApprovalStates.php new file mode 100644 index 0000000..6dc6cf7 --- /dev/null +++ b/database/factories/states/HasApprovalStates.php @@ -0,0 +1,28 @@ +state(fn (array $attributes) => [ + static::APPROVED_AT => now(), + ]); + } + + /** + * Indicate that the model is unapproved. + */ + public function unapproved(): self + { + return $this->state(fn (array $attributes) => [ + static::APPROVED_AT => null, + ]); + } +} diff --git a/database/seeders/OrderSeeder.php b/database/seeders/OrderSeeder.php index 733783e..1ab69fb 100644 --- a/database/seeders/OrderSeeder.php +++ b/database/seeders/OrderSeeder.php @@ -17,7 +17,7 @@ public function run(): void Order::factory()->forTicket($ticket)->create(); // Create an order that needs approval - Order::factory()->forTicket($ticket)->needsApproval()->create(); + Order::factory()->forTicket($ticket)->unapproved()->create(); }); // Mark some orders as non-billable diff --git a/database/seeders/TaskSeeder.php b/database/seeders/TaskSeeder.php index 2d4cb9a..58fd183 100644 --- a/database/seeders/TaskSeeder.php +++ b/database/seeders/TaskSeeder.php @@ -18,7 +18,7 @@ public function run(): void Task::factory()->forTicket($ticket)->create(); // Create a task that needs approval - Task::factory()->forTicket($ticket)->needsApproval()->create(); + Task::factory()->forTicket($ticket)->unapproved()->create(); }); // Mark some tasks as non-billable diff --git a/tests/Unit/Models/Concerns/HasApprovalTest.php b/tests/Unit/Models/Concerns/HasApprovalTest.php new file mode 100644 index 0000000..e6ac4b2 --- /dev/null +++ b/tests/Unit/Models/Concerns/HasApprovalTest.php @@ -0,0 +1,55 @@ + [Order::class], + 'Task' => [Task::class], +]); + +it('initializes model properties correctly', function (string $modelClass) { + // Arrange + $model = new $modelClass; + + // Assert + expect($model->getCasts())->toHaveKey('approved_at', 'datetime'); +})->with('models'); + +it('can determine if model is approved', function (string $modelClass) { + // Arrange + $model = $modelClass::factory()->approved()->create(); + + // Assert + expect($model->isApproved())->toBeTrue(); + expect($model->approved_at)->not->toBeNull(); +})->with('models'); + +it('can filter records by approved scope', function (string $modelClass) { + // Arrange + $modelClass::factory(2)->approved()->create(); + $modelClass::factory(1)->unapproved()->create(); + + // Act + $approvedModels = $modelClass::query()->approved()->get(); + + // Assert + expect($approvedModels)->toHaveCount(2); + expect($approvedModels->first()->isApproved())->toBeTrue(); +})->with('models'); + +it('can filter records by unapproved scope', function (string $modelClass) { + // Arrange + $modelClass::factory(2)->unapproved()->create(); + $modelClass::factory(1)->approved()->create(); + + // Act + $unapprovedModels = $modelClass::query()->unapproved()->get(); + + // Assert + expect($unapprovedModels)->toHaveCount(2); + expect($unapprovedModels->first()->isApproved())->toBeFalse(); +})->with('models'); diff --git a/tests/Unit/Models/OrderTest.php b/tests/Unit/Models/OrderTest.php index a5f6a5e..68b09d1 100644 --- a/tests/Unit/Models/OrderTest.php +++ b/tests/Unit/Models/OrderTest.php @@ -100,15 +100,3 @@ expect($orders)->toHaveCount(1); expect($orders->first()->status)->toBe($status); })->with(OrderStatus::cases()); - -it('can filter orders by approved scope', function () { - // Arrange - Order::factory()->create(); - - // Act - $orders = Order::query()->approved()->get(); - - // Assert - expect($orders)->toHaveCount(1); - expect($orders->first()->approved_at)->not->toBeNull(); -}); diff --git a/tests/Unit/Models/TaskTest.php b/tests/Unit/Models/TaskTest.php index 95a635a..4396b2f 100644 --- a/tests/Unit/Models/TaskTest.php +++ b/tests/Unit/Models/TaskTest.php @@ -5,6 +5,7 @@ use App\Models\Task; use App\Models\Ticket; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Carbon; uses(RefreshDatabase::class); @@ -19,6 +20,7 @@ expect($task->is_billable)->toBeTrue(); expect($task->status)->toBeInstanceOf(TaskStatus::class); expect($task->type)->toBeInstanceOf(TaskType::class); + expect($task->approved_at)->toBeInstanceOf(Carbon::class); }); it('can create a task for a ticket', function () { @@ -59,6 +61,7 @@ 'is_billable' => false, 'type' => TaskType::Inspection, 'status' => TaskStatus::Completed, + 'approved_at' => now(), ]); // Assert @@ -67,6 +70,7 @@ expect($task->is_billable)->toBeFalse(); expect($task->type)->toBe(TaskType::Inspection); expect($task->status)->toBe(TaskStatus::Completed); + expect($task->approved_at)->toBeInstanceOf(Carbon::class); }); it('can delete a task', function () { @@ -111,15 +115,3 @@ expect($tasks)->tohavecount(1); expect($tasks->first()->status)->toBe($status); })->with(TaskStatus::cases()); - -it('can filter tasks by approved scope', function () { - // Arrange - Task::factory()->create(); - - // Act - $tasks = Task::query()->approved()->get(); - - // Assert - expect($tasks)->toHaveCount(1); - expect($tasks->first()->approved_at)->not->toBeNull(); -}); From d98a8e7e8eedd1855cbe7e6447261d0d0e3ad5a6 Mon Sep 17 00:00:00 2001 From: Evren Ceyhan Date: Sat, 5 Jul 2025 22:09:03 +0200 Subject: [PATCH 05/11] Implement HasDueDate concern for Ticket and Invoice models --- app/Models/Concerns/HasDueDate.php | 49 ++++++++++++++++ app/Models/Invoice.php | 20 +------ app/Models/Ticket.php | 20 +------ database/factories/InvoiceFactory.php | 14 +---- database/factories/TicketFactory.php | 14 +---- .../factories/states/HasDueDateStates.php | 27 +++++++++ tests/Unit/Models/Concerns/HasDueDateTest.php | 58 +++++++++++++++++++ tests/Unit/Models/InvoiceTest.php | 30 ---------- tests/Unit/Models/TicketTest.php | 32 ---------- 9 files changed, 142 insertions(+), 122 deletions(-) create mode 100644 app/Models/Concerns/HasDueDate.php create mode 100644 database/factories/states/HasDueDateStates.php create mode 100644 tests/Unit/Models/Concerns/HasDueDateTest.php diff --git a/app/Models/Concerns/HasDueDate.php b/app/Models/Concerns/HasDueDate.php new file mode 100644 index 0000000..ecb86c2 --- /dev/null +++ b/app/Models/Concerns/HasDueDate.php @@ -0,0 +1,49 @@ +casts[static::DUE_DATE] = 'date'; + } + + /** + * Determine if the model is overdue. + */ + public function isOverdue(): bool + { + return $this->{static::DUE_DATE}?->startOfDay()->isPast() + && $this->isPending(); + } + + // SCOPES ////////////////////////////////////////////////////////////////////////////////////// + + /** + * Scope a query to only include overdue models. + */ + public function scopeOverdue(Builder $query): void + { + $query + ->where(static::DUE_DATE, '<', now()->startOfDay()) + ->pending(); + } + + /** + * Scope a query to only include models that are not overdue. + */ + public function scopeNotOverdue(Builder $query): void + { + $query + ->whereNotNull(static::DUE_DATE) + ->where(static::DUE_DATE, '>=', now()->startOfDay()); + } +} diff --git a/app/Models/Invoice.php b/app/Models/Invoice.php index 287ab6c..89b8e3c 100644 --- a/app/Models/Invoice.php +++ b/app/Models/Invoice.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Enums\InvoiceStatus; +use App\Models\Concerns\HasDueDate; use App\Models\Concerns\HasProgress; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -13,7 +14,7 @@ class Invoice extends Model { /** @use HasFactory<\Database\Factories\InvoiceFactory> */ - use HasFactory, HasProgress; + use HasDueDate, HasFactory, HasProgress; /** * The model's default values for attributes. @@ -50,7 +51,6 @@ class Invoice extends Model 'discount_amount' => 'float', 'paid_amount' => 'float', 'refunded_amount' => 'float', - 'due_date' => 'date', 'status' => InvoiceStatus::class, ]; @@ -97,20 +97,4 @@ public function scopeOfStatus(Builder $query, InvoiceStatus $status): void { $query->where('status', $status->value); } - - /** - * Scope a query to only include tickets that are overdue. - */ - public function scopeOverdue(Builder $query): void - { - $query->where('due_date', '<', now())->pending(); - } - - /** - * Determine if the invoice is overdue. - */ - public function isOverdue(): bool - { - return $this->due_date->isPast() && $this->isPending(); - } } diff --git a/app/Models/Ticket.php b/app/Models/Ticket.php index e1f3ffa..dec6fc9 100644 --- a/app/Models/Ticket.php +++ b/app/Models/Ticket.php @@ -4,6 +4,7 @@ use App\Enums\TicketPriority; use App\Enums\TicketStatus; +use App\Models\Concerns\HasDueDate; use App\Models\Concerns\HasProgress; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -15,7 +16,7 @@ class Ticket extends Model { /** @use HasFactory<\Database\Factories\TicketFactory> */ - use HasFactory, HasProgress; + use HasDueDate, HasFactory, HasProgress; /** * The model's default values for attributes. @@ -48,7 +49,6 @@ class Ticket extends Model protected $casts = [ 'priority' => TicketPriority::class, 'status' => TicketStatus::class, - 'due_date' => 'date', ]; // RELATIONS /////////////////////////////////////////////////////////////////////////////////// @@ -127,14 +127,6 @@ public function scopeOfStatus(Builder $query, TicketStatus $status): void $query->where('status', $status->value); } - /** - * Scope a query to only include tickets that are overdue. - */ - public function scopeOverdue(Builder $query): void - { - $query->where('due_date', '<', now())->pending(); - } - /** * Determine if the ticket is assignable to a user. */ @@ -158,12 +150,4 @@ public function unassign(): void { $this->assignee()->dissociate()->save(); } - - /** - * Determine if the ticket is overdue. - */ - public function isOverdue(): bool - { - return $this->due_date->isPast() && $this->isPending(); - } } diff --git a/database/factories/InvoiceFactory.php b/database/factories/InvoiceFactory.php index de1ae93..71c6dbd 100644 --- a/database/factories/InvoiceFactory.php +++ b/database/factories/InvoiceFactory.php @@ -4,6 +4,7 @@ use App\Enums\InvoiceStatus; use App\Models\Ticket; +use Database\Factories\States\HasDueDateStates; use Database\Factories\States\HasProgressStates; use Illuminate\Database\Eloquent\Factories\Factory; @@ -14,7 +15,7 @@ */ class InvoiceFactory extends Factory { - use HasProgressStates; + use HasDueDateStates, HasProgressStates; /** * Define the model's default state. @@ -96,15 +97,4 @@ public function refunded(): static 'refunded_amount' => $attributes['total'] ?? 0, ]); } - - /** - * Indicate that the invoice is overdue. - */ - public function overdue(): static - { - return $this->state(fn (array $attributes) => [ - 'due_date' => now()->subDays(1), - 'status' => InvoiceStatus::Issued, - ]); - } } diff --git a/database/factories/TicketFactory.php b/database/factories/TicketFactory.php index 2fbad68..80bd2c8 100644 --- a/database/factories/TicketFactory.php +++ b/database/factories/TicketFactory.php @@ -6,6 +6,7 @@ use App\Enums\TicketStatus; use App\Models\Device; use App\Models\User; +use Database\Factories\States\HasDueDateStates; use Database\Factories\States\HasProgressStates; use Illuminate\Database\Eloquent\Factories\Factory; @@ -17,7 +18,7 @@ */ class TicketFactory extends Factory { - use HasProgressStates; + use HasDueDateStates, HasProgressStates; /** * Define the model's default state. @@ -81,15 +82,4 @@ public function ofStatus(TicketStatus $status): self 'status' => $status, ]); } - - /** - * Indicate that the ticket is overdue. - */ - public function overdue(): self - { - return $this->state(fn (array $attributes) => [ - 'due_date' => now()->subDay(), - 'status' => TicketStatus::New, - ]); - } } diff --git a/database/factories/states/HasDueDateStates.php b/database/factories/states/HasDueDateStates.php new file mode 100644 index 0000000..beeaded --- /dev/null +++ b/database/factories/states/HasDueDateStates.php @@ -0,0 +1,27 @@ +state(fn (array $attributes) => [ + static::DUE_DATE => now()->addDays($days)->startOfDay(), + ]); + } + + /** + * Indicate that the model is overdue. + */ + public function overdue(): static + { + return $this->dueInDays(-1)->pending(); + } +} diff --git a/tests/Unit/Models/Concerns/HasDueDateTest.php b/tests/Unit/Models/Concerns/HasDueDateTest.php new file mode 100644 index 0000000..d5eb0d5 --- /dev/null +++ b/tests/Unit/Models/Concerns/HasDueDateTest.php @@ -0,0 +1,58 @@ + [Ticket::class], + 'Invoice' => [Invoice::class], +]); + +it('initializes model properties correctly', function (string $modelClass) { + // Arrange + $model = new $modelClass; + + // Assert + expect($model->getCasts())->toHaveKey('due_date', 'date'); +})->with('models'); + +it('can determine if model is overdue', function (string $modelClass) { + // Arrange + $pendingModel = $modelClass::factory()->overdue()->pending()->create(); + $completedModel = $modelClass::factory()->overdue()->complete()->create(); + + // Assert + expect($pendingModel->isOverdue())->toBeTrue(); + expect($pendingModel->due_date->isPast())->toBeTrue(); + expect($completedModel->isOverdue())->toBeFalse(); + expect($completedModel->due_date->isPast())->toBeTrue(); +})->with('models'); + +it('can filter records by overdue scope', function (string $modelClass) { + // Arrange + $modelClass::factory(2)->overdue()->create(); + $modelClass::factory(1)->dueInDays()->create(); + + // Act + $overdueModels = $modelClass::query()->overdue()->get(); + + // Assert + expect($overdueModels)->toHaveCount(2); + expect($overdueModels->first()->isOverdue())->toBeTrue(); +})->with('models'); + +it('can filter records by not overdue scope', function (string $modelClass) { + // Arrange + $modelClass::factory(2)->dueInDays()->create(); + $modelClass::factory(1)->overdue()->create(); + + // Act + $notOverdueModels = $modelClass::query()->notOverdue()->get(); + + // Assert + expect($notOverdueModels)->toHaveCount(2); + expect($notOverdueModels->first()->isOverdue())->toBeFalse(); +})->with('models'); diff --git a/tests/Unit/Models/InvoiceTest.php b/tests/Unit/Models/InvoiceTest.php index eba4665..b4a9218 100644 --- a/tests/Unit/Models/InvoiceTest.php +++ b/tests/Unit/Models/InvoiceTest.php @@ -51,15 +51,6 @@ expect($invoice->refunded_amount)->toBe($invoice->total); }); -it('can create an overdue invoice', function () { - // Arrange - $invoice = Invoice::factory()->overdue()->create(); - - // Assert - expect($invoice->isOverdue())->toBeTrue(); - expect($invoice->status)->toBe(InvoiceStatus::Issued); -}); - it('can create an invoice of status', function (InvoiceStatus $status) { // Arrange & Act $invoice = Invoice::factory()->ofStatus($status)->create(); @@ -140,24 +131,3 @@ expect($invoices)->toHaveCount(2); expect($invoices->first()->status)->toBe($status); })->with(InvoiceStatus::cases()); - -it('can filter invoices by overdue scope', function () { - // Arrange - Invoice::factory()->create(); - Invoice::factory()->overdue()->create(); - - // Act - $overdueInvoices = Invoice::query()->overdue()->get(); - - // Assert - expect($overdueInvoices)->toHaveCount(1); - expect($overdueInvoices->first()->isOverdue())->toBeTrue(); -}); - -it('can determine if an invoice is overdue', function () { - // Arrange - $invoice = Invoice::factory()->overdue()->create(); - - // Assert - expect($invoice->isOverdue())->toBeTrue(); -}); diff --git a/tests/Unit/Models/TicketTest.php b/tests/Unit/Models/TicketTest.php index 12ae90b..d83eeee 100644 --- a/tests/Unit/Models/TicketTest.php +++ b/tests/Unit/Models/TicketTest.php @@ -49,15 +49,6 @@ expect($ticket->status)->toBe(TicketStatus::InProgress); }); -it('can create an overdue ticket', function () { - // Arrange - $ticket = Ticket::factory()->overdue()->create(); - - // Assert - expect($ticket->due_date->isPast())->toBeTrue(); - expect($ticket->status)->not->toBe(TicketStatus::Closed); -}); - it('can update a ticket', function () { // Arrange $ticket = Ticket::factory()->create(); @@ -193,26 +184,3 @@ expect($tickets->count())->toBe(1); expect($tickets->first()->status)->toBe($status); })->with(TicketStatus::cases()); - -it('can determine if a ticket is overdue', function () { - // Arrange - $ticket = Ticket::factory()->overdue()->create(); - - // Assert - expect($ticket->isOverdue())->toBeTrue(); - expect($ticket->due_date->isPast())->toBeTrue(); - expect($ticket->status)->not->toBe(TicketStatus::Closed); -}); - -it('can filter tickets by overdue scope', function () { - // Arrange - Ticket::factory()->create(); - Ticket::factory()->overdue()->create(); - - // Act - $tickets = Ticket::overdue()->get(); - - // Assert - expect($tickets->count())->toBe(1); - expect($tickets->first()->due_date->isPast())->toBeTrue(); -}); From f881860bc89b03d07954e0a1d916d44c49dc95e3 Mon Sep 17 00:00:00 2001 From: Evren Ceyhan Date: Sun, 6 Jul 2025 12:11:49 +0200 Subject: [PATCH 06/11] Implement Assignable concern for Ticket model --- app/Models/Concerns/Assignable.php | 64 +++++++++++++++ app/Models/Ticket.php | 43 +--------- database/factories/TicketFactory.php | 14 +--- .../factories/states/HasAssignableStates.php | 38 +++++++++ tests/Unit/Models/Concerns/AssignableTest.php | 80 +++++++++++++++++++ tests/Unit/Models/TicketTest.php | 62 -------------- 6 files changed, 186 insertions(+), 115 deletions(-) create mode 100644 app/Models/Concerns/Assignable.php create mode 100644 database/factories/states/HasAssignableStates.php create mode 100644 tests/Unit/Models/Concerns/AssignableTest.php diff --git a/app/Models/Concerns/Assignable.php b/app/Models/Concerns/Assignable.php new file mode 100644 index 0000000..67e6ae7 --- /dev/null +++ b/app/Models/Concerns/Assignable.php @@ -0,0 +1,64 @@ +{static::ASSIGNEE_ID} !== null; + } + + /** + * Assign the model to a user. + */ + public function assignTo(User $user): void + { + $this->assignee()->associate($user)->save(); + } + + /** + * Unassign the model from a user. + */ + public function unassign(): void + { + $this->assignee()->dissociate()->save(); + } + + // RELATIONS /////////////////////////////////////////////////////////////////////////////////// + + /** + * Get the assignee (user) for the model. + */ + public function assignee(): BelongsTo + { + return $this->belongsTo(User::class, static::ASSIGNEE_ID); + } + + // SCOPES ////////////////////////////////////////////////////////////////////////////////////// + + /** + * Scope a query to only include models that are already assigned. + */ + public function scopeAssigned(Builder $query): void + { + $query->whereNotNull(static::ASSIGNEE_ID); + } + + /** + * Scope a query to only include models that are unassigned. + */ + public function scopeUnassigned(Builder $query): void + { + $query->whereNull(static::ASSIGNEE_ID); + } +} diff --git a/app/Models/Ticket.php b/app/Models/Ticket.php index dec6fc9..3e8b3f2 100644 --- a/app/Models/Ticket.php +++ b/app/Models/Ticket.php @@ -4,6 +4,7 @@ use App\Enums\TicketPriority; use App\Enums\TicketStatus; +use App\Models\Concerns\Assignable; use App\Models\Concerns\HasDueDate; use App\Models\Concerns\HasProgress; use Illuminate\Database\Eloquent\Builder; @@ -16,7 +17,7 @@ class Ticket extends Model { /** @use HasFactory<\Database\Factories\TicketFactory> */ - use HasDueDate, HasFactory, HasProgress; + use Assignable, HasDueDate, HasFactory, HasProgress; /** * The model's default values for attributes. @@ -53,14 +54,6 @@ class Ticket extends Model // RELATIONS /////////////////////////////////////////////////////////////////////////////////// - /** - * Get the assignee (user) for the ticket. - */ - public function assignee(): BelongsTo - { - return $this->belongsTo(User::class, 'assignee_id'); - } - /** * Get the device for the ticket. */ @@ -103,14 +96,6 @@ public function invoice(): HasOne // SCOPES ////////////////////////////////////////////////////////////////////////////////////// - /** - * Scope a query to only include tickets that are assignable. - */ - public function scopeAssignable(Builder $query): void - { - $query->whereNull('assignee_id'); - } - /** * Scope a query to only include tickets with the specified priority. */ @@ -126,28 +111,4 @@ public function scopeOfStatus(Builder $query, TicketStatus $status): void { $query->where('status', $status->value); } - - /** - * Determine if the ticket is assignable to a user. - */ - public function isAssignable(): bool - { - return ! $this->assignee()->exists(); - } - - /** - * Assign the ticket to a user. - */ - public function assignTo(User $user): void - { - $this->assignee()->associate($user)->save(); - } - - /** - * Unassign the ticket from a user. - */ - public function unassign(): void - { - $this->assignee()->dissociate()->save(); - } } diff --git a/database/factories/TicketFactory.php b/database/factories/TicketFactory.php index 80bd2c8..bb45739 100644 --- a/database/factories/TicketFactory.php +++ b/database/factories/TicketFactory.php @@ -5,7 +5,7 @@ use App\Enums\TicketPriority; use App\Enums\TicketStatus; use App\Models\Device; -use App\Models\User; +use Database\Factories\States\HasAssignableStates; use Database\Factories\States\HasDueDateStates; use Database\Factories\States\HasProgressStates; use Illuminate\Database\Eloquent\Factories\Factory; @@ -18,7 +18,7 @@ */ class TicketFactory extends Factory { - use HasDueDateStates, HasProgressStates; + use HasAssignableStates, HasDueDateStates, HasProgressStates; /** * Define the model's default state. @@ -39,16 +39,6 @@ public function definition(): array // RELATIONS /////////////////////////////////////////////////////////////////////////////////// - /** - * Indicate that the ticket is assigned to a specific user. - */ - public function forAssignee(User $user): self - { - return $this->state(fn (array $attributes) => [ - 'assignee_id' => $user->id, - ]); - } - /** * Indicate that the ticket belongs to a specific device. */ diff --git a/database/factories/states/HasAssignableStates.php b/database/factories/states/HasAssignableStates.php new file mode 100644 index 0000000..494932c --- /dev/null +++ b/database/factories/states/HasAssignableStates.php @@ -0,0 +1,38 @@ +state(fn (array $attributes) => [ + 'assignee_id' => $user->id, + ]); + } + + /** + * Indicate that the model is assigned to a user. + */ + public function assigned(): self + { + return $this->state(fn (array $attributes) => [ + 'assignee_id' => User::factory(), + ]); + } + + /** + * Indicate that the model is not assigned to any user. + */ + public function unassigned(): self + { + return $this->state(fn (array $attributes) => [ + 'assignee_id' => null, + ]); + } +} diff --git a/tests/Unit/Models/Concerns/AssignableTest.php b/tests/Unit/Models/Concerns/AssignableTest.php new file mode 100644 index 0000000..51418a0 --- /dev/null +++ b/tests/Unit/Models/Concerns/AssignableTest.php @@ -0,0 +1,80 @@ + [Ticket::class], +]); + +it('can determine if model is assignable', function (string $modelClass) { + // Arrange + $model = $modelClass::factory()->assigned()->create(); + + // Assert + expect($model->isAssigned())->toBeTrue(); + expect($model->assignee_id)->not->toBeNull(); +})->with('models'); + +it('has an assignee relation', function (string $modelClass) { + // Arrange + $user = User::factory()->create(); + $model = $modelClass::factory()->forAssignee($user)->create(); + + // Assert + expect($model->assignee)->toBeInstanceOf(User::class); + expect($model->assignee->id)->toBe($user->id); +})->with('models'); + +it('can assign a model to a user', function (string $modelClass) { + // Arrange + $user = User::factory()->create(); + $model = $modelClass::factory()->unassigned()->create(); + + // Act + $model->assignTo($user); + + // Assert + expect($model->assignee)->toBeInstanceOf(User::class); + expect($model->assignee->id)->toBe($user->id); +})->with('models'); + +it('can unassign a model from a user', function (string $modelClass) { + // Arrange + $model = $modelClass::factory()->assigned()->create(); + + // Act + $model->unassign(); + + // Assert + expect($model->assignee)->toBeNull(); +})->with('models'); + +it('can filter records by assigned scope', function (string $modelClass) { + // Arrange + $modelClass::factory(2)->assigned()->create(); + $modelClass::factory(1)->unassigned()->create(); + + // Act + $assignedModels = $modelClass::query()->assigned()->get(); + + // Assert + expect($assignedModels)->toHaveCount(2); + expect($assignedModels->first()->isAssigned())->toBeTrue(); +})->with('models'); + +it('can filter records by unassigned scope', function (string $modelClass) { + // Arrange + $modelClass::factory(2)->unassigned()->create(); + $modelClass::factory(1)->assigned()->create(); + + // Act + $unassignedModels = $modelClass::query()->unassigned()->get(); + + // Assert + expect($unassignedModels)->toHaveCount(2); + expect($unassignedModels->first()->isAssigned())->toBeFalse(); +})->with('models'); diff --git a/tests/Unit/Models/TicketTest.php b/tests/Unit/Models/TicketTest.php index d83eeee..088ba14 100644 --- a/tests/Unit/Models/TicketTest.php +++ b/tests/Unit/Models/TicketTest.php @@ -5,7 +5,6 @@ use App\Models\Customer; use App\Models\Device; use App\Models\Ticket; -use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; uses(RefreshDatabase::class); @@ -23,16 +22,6 @@ expect($ticket->due_date)->not->toBeNull(); }); -it('can create a ticket with an assignee', function () { - // Arrange - $user = User::factory()->create(); - $ticket = Ticket::factory()->forAssignee($user)->create(); - - // Assert - expect($ticket->assignee_id)->toBe($user->id); - expect($ticket->assignee)->toBeInstanceOf(User::class); -}); - it('can create a ticket of priority', function () { // Arrange $ticket = Ticket::factory()->ofPriority(TicketPriority::High)->create(); @@ -110,57 +99,6 @@ expect($ticket->orders)->toHaveCount(2); }); -it('can assign a ticket to a user', function () { - // Arrange - $user = User::factory()->create(); - $ticket = Ticket::factory()->create(); - - // Act - $ticket->assignTo($user); - - // Assert - expect($ticket->assignee_id)->toBe($user->id); - expect($ticket->assignee)->toBeInstanceOf(User::class); -}); - -it('can unassign a ticket from a user', function () { - // Arrange - $user = User::factory()->create(); - $ticket = Ticket::factory()->forAssignee($user)->create(); - - // Act - $ticket->unassign(); - - // Assert - expect($ticket->assignee_id)->toBeNull(); - expect($ticket->assignee)->toBeNull(); -}); - -it('can determine if a ticket is assignable', function () { - // Arrange - $ticket = Ticket::factory()->create(); - - // Act - $isAssignable = $ticket->isAssignable(); - - // Assert - expect($isAssignable)->toBeTrue(); -}); - -it('can filter tickets by assignable scope', function () { - // Arrange - $user = User::factory()->create(); - Ticket::factory()->forAssignee($user)->create(); - Ticket::factory()->create(); - - // Act - $tickets = Ticket::assignable()->get(); - - // Assert - expect($tickets->count())->toBe(1); - expect($tickets->first()->assignee_id)->toBeNull(); -}); - it('can filter tickets by priority scope', function (TicketPriority $priority) { // Arrange Ticket::factory()->ofPriority($priority)->create(); From 5305a94114982109839d131bb4d284cfb18a42b8 Mon Sep 17 00:00:00 2001 From: Evren Ceyhan Date: Mon, 7 Jul 2025 16:02:50 +0200 Subject: [PATCH 07/11] Implement HasWarranty concern for Device model --- app/Models/Concerns/HasWarranty.php | 36 ++++++++++++++++ app/Models/Device.php | 22 +--------- database/factories/DeviceFactory.php | 14 +------ .../factories/states/HasWarrantyStates.php | 28 +++++++++++++ database/seeders/DeviceSeeder.php | 2 +- .../Unit/Models/Concerns/HasWarrantyTest.php | 40 ++++++++++++++++++ tests/Unit/Models/DeviceTest.php | 41 ------------------- 7 files changed, 109 insertions(+), 74 deletions(-) create mode 100644 app/Models/Concerns/HasWarranty.php create mode 100644 database/factories/states/HasWarrantyStates.php create mode 100644 tests/Unit/Models/Concerns/HasWarrantyTest.php diff --git a/app/Models/Concerns/HasWarranty.php b/app/Models/Concerns/HasWarranty.php new file mode 100644 index 0000000..da9a48f --- /dev/null +++ b/app/Models/Concerns/HasWarranty.php @@ -0,0 +1,36 @@ +casts[static::WARRANTY_EXPIRE_DATE] = 'date'; + } + + /** + * Determine if the model has valid warranty. + */ + public function hasWarranty(): bool + { + return $this->warranty_expire_date > now(); + } + + // SCOPES ////////////////////////////////////////////////////////////////////////////////////// + + /** + * Scope a query to only include models with warranty. + */ + public function scopeWithWarranty(Builder $query): void + { + $query->where(static::WARRANTY_EXPIRE_DATE, '>', now()); + } +} diff --git a/app/Models/Device.php b/app/Models/Device.php index 071dfd9..7994914 100644 --- a/app/Models/Device.php +++ b/app/Models/Device.php @@ -5,6 +5,7 @@ use App\Enums\DeviceStatus; use App\Enums\DeviceType; use App\Models\Concerns\HasProgress; +use App\Models\Concerns\HasWarranty; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -14,7 +15,7 @@ class Device extends Model { /** @use HasFactory<\Database\Factories\DeviceFactory> */ - use HasFactory, HasProgress; + use HasFactory, HasProgress, HasWarranty; /** * The model's default values for attributes. @@ -48,7 +49,6 @@ class Device extends Model */ protected $casts = [ 'purchase_date' => 'date', - 'warranty_expire_date' => 'date', 'type' => DeviceType::class, 'status' => DeviceStatus::class, ]; @@ -73,14 +73,6 @@ public function tickets(): HasMany // SCOPES ////////////////////////////////////////////////////////////////////////////////////// - /** - * Scope a query to only include devices with warranty. - */ - public function scopeWithWarranty(Builder $query): void - { - $query->where('warranty_expire_date', '>', now()); - } - /** * Scope a query to only include devices of a given type. */ @@ -96,14 +88,4 @@ public function scopeOfStatus(Builder $query, DeviceStatus $status): void { $query->where('status', $status->value); } - - // METHODS ///////////////////////////////////////////////////////////////////////////////////// - - /** - * Determine if the device has valid warranty. - */ - public function hasWarranty(): bool - { - return $this->warranty_expire_date > now(); - } } diff --git a/database/factories/DeviceFactory.php b/database/factories/DeviceFactory.php index 16000ee..fed296e 100644 --- a/database/factories/DeviceFactory.php +++ b/database/factories/DeviceFactory.php @@ -6,6 +6,7 @@ use App\Enums\DeviceType; use App\Models\Customer; use Database\Factories\States\HasProgressStates; +use Database\Factories\States\HasWarrantyStates; use Illuminate\Database\Eloquent\Factories\Factory; /** @@ -15,7 +16,7 @@ */ class DeviceFactory extends Factory { - use HasProgressStates; + use HasProgressStates, HasWarrantyStates; const VERSIONS = [ 'iMac' => ['21"', '27"'], @@ -140,17 +141,6 @@ public function withoutPurchaseDate(): self ]); } - /** - * Indicate that the device is out of warranty. - */ - public function outOfWarranty(): self - { - return $this->state(fn (array $attributes) => [ - 'purchase_date' => now()->subYears(2), - 'warranty_expire_date' => now()->subYear(), - ]); - } - /** * Indicate that the device is of the given type. */ diff --git a/database/factories/states/HasWarrantyStates.php b/database/factories/states/HasWarrantyStates.php new file mode 100644 index 0000000..e25ef31 --- /dev/null +++ b/database/factories/states/HasWarrantyStates.php @@ -0,0 +1,28 @@ +state(fn (array $attributes) => [ + static::WARRANTY_EXPIRE_DATE => now()->addYear(), + ]); + } + + /** + * Indicate that the model has no warranty. + */ + public function withoutWarranty(): self + { + return $this->state(fn (array $attributes) => [ + static::WARRANTY_EXPIRE_DATE => null, + ]); + } +} diff --git a/database/seeders/DeviceSeeder.php b/database/seeders/DeviceSeeder.php index a4944f3..8c3a877 100644 --- a/database/seeders/DeviceSeeder.php +++ b/database/seeders/DeviceSeeder.php @@ -23,7 +23,7 @@ public function run(): void 2 => $deviceFactory->withoutBrand()->create(), 3 => $deviceFactory->withoutSerialNumber()->create(), 4 => $deviceFactory->withoutPurchaseDate()->create(), - 5 => $deviceFactory->outOfWarranty()->create(), + 5 => $deviceFactory->withoutWarranty()->create(), }; }); } diff --git a/tests/Unit/Models/Concerns/HasWarrantyTest.php b/tests/Unit/Models/Concerns/HasWarrantyTest.php new file mode 100644 index 0000000..cba3734 --- /dev/null +++ b/tests/Unit/Models/Concerns/HasWarrantyTest.php @@ -0,0 +1,40 @@ + [Device::class], +]); + +it('initializes model properties correctly', function (string $modelClass) { + // Arrange + $model = new $modelClass; + + // Assert + expect($model->getCasts())->toHaveKey('warranty_expire_date', 'date'); +})->with('models'); + +it('can determine if model has warranty', function (string $modelClass) { + // Arrange + $model = $modelClass::factory()->withWarranty()->create(); + + // Assert + expect($model->hasWarranty())->toBeTrue(); + expect($model->warranty_expire_date->isFuture())->toBeTrue(); +})->with('models'); + +it('can filter records by with warranty scope', function (string $modelClass) { + // Arrange + $modelClass::factory(2)->withWarranty()->create(); + $modelClass::factory(1)->withoutWarranty()->create(); + + // Act + $warrantyModels = $modelClass::query()->withWarranty()->get(); + + // Assert + expect($warrantyModels)->toHaveCount(2); + expect($warrantyModels->first()->hasWarranty())->toBeTrue(); +})->with('models'); diff --git a/tests/Unit/Models/DeviceTest.php b/tests/Unit/Models/DeviceTest.php index 504a852..f685752 100644 --- a/tests/Unit/Models/DeviceTest.php +++ b/tests/Unit/Models/DeviceTest.php @@ -44,15 +44,6 @@ expect($device->warranty_expire_date)->toBeNull(); }); -it('can create a device out of warranty', function () { - // Arrange - $device = Device::factory()->outOfWarranty()->create(); - - // Assert - expect($device->purchase_date->isPast())->toBeTrue(); - expect($device->warranty_expire_date->isPast())->toBeTrue(); -}); - it('can create a device of type', function (DeviceType $type) { // Arrange $device = Device::factory()->ofType($type)->create(); @@ -111,22 +102,6 @@ expect($device->tickets)->toHaveCount(2); }); -it('can determine if device has warranty', function () { - // Arrange - $device = Device::factory()->outOfWarranty()->create(); - - // Assert - expect($device->hasWarranty())->toBeFalse(); - - // Act - $device->purchase_date = now()->subMonths(3); - $device->warranty_expire_date = now()->addMonths(9); - $device->save(); - - // Assert - expect($device->hasWarranty())->toBeTrue(); -}); - it('can get the customer that owns the device', function () { // Arrange $customer = Customer::factory()->create(); @@ -136,22 +111,6 @@ expect($device->customer->id)->toBe($customer->id); }); -it('can filter devices with warranty scope', function () { - // Arrange - Device::factory()->outOfWarranty()->create(); - $deviceWithWarranty = Device::factory()->create([ - 'purchase_date' => now()->subMonths(3), - 'warranty_expire_date' => now()->addMonths(9), - ]); - - // Act - $devicesWithWarranty = Device::withWarranty()->get(); - - // Assert - expect($devicesWithWarranty)->toHaveCount(1); - expect($devicesWithWarranty->first()->id)->toBe($deviceWithWarranty->id); -}); - it('can filter devices by type scope', function (DeviceType $type) { // Arrange Device::factory()->ofType($type)->create(); From 0b0fda2531e27ad00b61f921ce3e6c0bfc9b9ec1 Mon Sep 17 00:00:00 2001 From: Evren Ceyhan Date: Fri, 11 Jul 2025 13:51:48 +0200 Subject: [PATCH 08/11] Implement HasPriority concern for Ticket model --- app/Enums/Priority.php | 36 +++++++++ app/Enums/TicketPriority.php | 27 ------- app/Models/Concerns/HasPriority.php | 60 +++++++++++++++ app/Models/Ticket.php | 14 +--- database/factories/TicketFactory.php | 17 +---- .../factories/states/HasPriorityStates.php | 52 +++++++++++++ ...2025_06_30_215857_create_tickets_table.php | 4 +- docs/development-roadmap.md | 2 +- .../Unit/Models/Concerns/HasPriorityTest.php | 73 +++++++++++++++++++ tests/Unit/Models/TicketTest.php | 28 +------ 10 files changed, 234 insertions(+), 79 deletions(-) create mode 100644 app/Enums/Priority.php delete mode 100644 app/Enums/TicketPriority.php create mode 100644 app/Models/Concerns/HasPriority.php create mode 100644 database/factories/states/HasPriorityStates.php create mode 100644 tests/Unit/Models/Concerns/HasPriorityTest.php diff --git a/app/Enums/Priority.php b/app/Enums/Priority.php new file mode 100644 index 0000000..3ff1f35 --- /dev/null +++ b/app/Enums/Priority.php @@ -0,0 +1,36 @@ +casts[static::PRIORITY] = Priority::class; + + $this->attributes[static::PRIORITY] = Priority::Medium; + } + + /** + * Determine if the model has urgent priority. + */ + public function isUrgent(): bool + { + return $this->{static::PRIORITY} === Priority::Urgent; + } + + // SCOPES ////////////////////////////////////////////////////////////////////////////////////// + + /** + * Scope a query to only include models with the given priority. + */ + public function scopeOfPriority(Builder $query, Priority $priority): void + { + $query->where(static::PRIORITY, $priority->value); + } + + /** + * Scope a query to only include urgent priority models. + */ + public function scopeUrgent(Builder $query): void + { + $query->ofPriority(Priority::Urgent); + } + + /** + * Scope a query to order models by priority from high to low. + */ + public function scopePrioritized(Builder $query): void + { + $query->orderByRaw('CASE '.static::PRIORITY.' + WHEN ? THEN 1 + WHEN ? THEN 2 + WHEN ? THEN 3 + WHEN ? THEN 4 + END DESC', Priority::values()); + } +} diff --git a/app/Models/Ticket.php b/app/Models/Ticket.php index 3e8b3f2..ccd8830 100644 --- a/app/Models/Ticket.php +++ b/app/Models/Ticket.php @@ -2,10 +2,10 @@ namespace App\Models; -use App\Enums\TicketPriority; use App\Enums\TicketStatus; use App\Models\Concerns\Assignable; use App\Models\Concerns\HasDueDate; +use App\Models\Concerns\HasPriority; use App\Models\Concerns\HasProgress; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -17,7 +17,7 @@ class Ticket extends Model { /** @use HasFactory<\Database\Factories\TicketFactory> */ - use Assignable, HasDueDate, HasFactory, HasProgress; + use Assignable, HasDueDate, HasFactory, HasPriority, HasProgress; /** * The model's default values for attributes. @@ -25,7 +25,6 @@ class Ticket extends Model * @var array */ protected $attributes = [ - 'priority' => TicketPriority::Normal, 'status' => TicketStatus::New, ]; @@ -48,7 +47,6 @@ class Ticket extends Model * @var array */ protected $casts = [ - 'priority' => TicketPriority::class, 'status' => TicketStatus::class, ]; @@ -96,14 +94,6 @@ public function invoice(): HasOne // SCOPES ////////////////////////////////////////////////////////////////////////////////////// - /** - * Scope a query to only include tickets with the specified priority. - */ - public function scopeOfPriority(Builder $query, TicketPriority $priority): void - { - $query->where('priority', $priority->value); - } - /** * Scope a query to only include tickets with the specified status. */ diff --git a/database/factories/TicketFactory.php b/database/factories/TicketFactory.php index bb45739..34fa1e8 100644 --- a/database/factories/TicketFactory.php +++ b/database/factories/TicketFactory.php @@ -2,11 +2,12 @@ namespace Database\Factories; -use App\Enums\TicketPriority; +use App\Enums\Priority; use App\Enums\TicketStatus; use App\Models\Device; use Database\Factories\States\HasAssignableStates; use Database\Factories\States\HasDueDateStates; +use Database\Factories\States\HasPriorityStates; use Database\Factories\States\HasProgressStates; use Illuminate\Database\Eloquent\Factories\Factory; @@ -18,7 +19,7 @@ */ class TicketFactory extends Factory { - use HasAssignableStates, HasDueDateStates, HasProgressStates; + use HasAssignableStates, HasDueDateStates, HasPriorityStates, HasProgressStates; /** * Define the model's default state. @@ -31,7 +32,7 @@ public function definition(): array 'device_id' => Device::factory(), 'title' => fake()->sentence(), 'description' => fake()->paragraph(), - 'priority' => TicketPriority::Normal, + 'priority' => Priority::Medium, 'status' => TicketStatus::New, 'due_date' => now()->addWeek(), ]; @@ -53,16 +54,6 @@ public function forDevice(Device $device): self // STATES ////////////////////////////////////////////////////////////////////////////////////// - /** - * Indicate that the ticket is of the given priority. - */ - public function ofPriority(TicketPriority $priority): self - { - return $this->state(fn (array $attributes) => [ - 'priority' => $priority, - ]); - } - /** * Indicate that the ticket is of the given status. */ diff --git a/database/factories/states/HasPriorityStates.php b/database/factories/states/HasPriorityStates.php new file mode 100644 index 0000000..6955cb4 --- /dev/null +++ b/database/factories/states/HasPriorityStates.php @@ -0,0 +1,52 @@ +state(fn (array $attributes) => [ + static::PRIORITY => $priority, + ]); + } + + /** + * Indicate that the model has low priority. + */ + public function lowPriority(): self + { + return $this->ofPriority(Priority::Low); + } + + /** + * Indicate that the model has medium priority. + */ + public function mediumPriority(): self + { + return $this->ofPriority(Priority::Medium); + } + + /** + * Indicate that the model has high priority. + */ + public function highPriority(): self + { + return $this->ofPriority(Priority::High); + } + + /** + * Indicate that the model has urgent priority. + */ + public function urgent(): self + { + return $this->ofPriority(Priority::Urgent); + } +} diff --git a/database/migrations/2025_06_30_215857_create_tickets_table.php b/database/migrations/2025_06_30_215857_create_tickets_table.php index 4ee4113..0aea0a3 100644 --- a/database/migrations/2025_06_30_215857_create_tickets_table.php +++ b/database/migrations/2025_06_30_215857_create_tickets_table.php @@ -1,6 +1,6 @@ foreignId('device_id')->constrained()->cascadeOnDelete(); $table->string('title'); $table->text('description'); - $table->enum('priority', TicketPriority::values())->default(TicketPriority::Normal); + $table->enum('priority', Priority::values())->default(Priority::Medium); $table->enum('status', TicketStatus::values())->default(TicketStatus::New); $table->date('due_date')->nullable(); $table->timestamps(); diff --git a/docs/development-roadmap.md b/docs/development-roadmap.md index cff60e8..d989790 100644 --- a/docs/development-roadmap.md +++ b/docs/development-roadmap.md @@ -136,7 +136,7 @@ php artisan test --filter=User #### Tasks: - [ ] **Ticket Migration & Model** - [ ] Create tickets migration with foreign keys (customer_id, device_id, assigned_to) - - [ ] Create `TicketPriority` and `TicketStatus` enums + - [ ] Create `Priority` and `TicketStatus` enums - [ ] Add computed columns (progress_percentage, is_overdue) - [ ] Add counter fields (total_tasks, completed_tasks, etc.) diff --git a/tests/Unit/Models/Concerns/HasPriorityTest.php b/tests/Unit/Models/Concerns/HasPriorityTest.php new file mode 100644 index 0000000..6d44088 --- /dev/null +++ b/tests/Unit/Models/Concerns/HasPriorityTest.php @@ -0,0 +1,73 @@ + [Ticket::class], +]); + +it('initializes model properties correctly', function (string $modelClass) { + // Arrange + $model = new $modelClass; + + // Assert + expect($model->getCasts())->toHaveKey('priority', Priority::class); + expect($model->getAttributes())->toHaveKey('priority', Priority::Medium); +})->with('models'); + +it('can determine if model is urgent', function (string $modelClass) { + // Arrange + $urgentModel = $modelClass::factory()->urgent()->create(); + $nonUrgentModel = $modelClass::factory()->lowPriority()->create(); + + // Assert + expect($urgentModel->isUrgent())->toBeTrue(); + expect($urgentModel->priority)->toBe(Priority::Urgent); + expect($nonUrgentModel->isUrgent())->toBeFalse(); + expect($nonUrgentModel->priority)->toBe(Priority::Low); +})->with('models'); + +it('can filter records by priority scope', function (string $modelClass, Priority $priority) { + // Arrange + $modelClass::factory()->ofPriority($priority)->create(); + + // Act + $models = $modelClass::query()->ofPriority($priority)->get(); + + // Assert + expect($models)->toHaveCount(1); + expect($models->first()->priority)->toBe($priority); +})->with('models')->with(Priority::cases()); + +it('can filter records by urgent scope', function (string $modelClass) { + // Arrange + $modelClass::factory(2)->urgent()->create(); + $modelClass::factory(1)->mediumPriority()->create(); + + // Act + $models = $modelClass::query()->urgent()->get(); + + // Assert + expect($models)->toHaveCount(2); + expect($models->first()->isUrgent())->toBeTrue(); +})->with('models'); + +it('can order records by priority', function (string $modelClass) { + // Arrange + $modelClass::factory()->mediumPriority()->create(); + $modelClass::factory()->lowPriority()->create(); + $modelClass::factory()->urgent()->create(); + $modelClass::factory()->highPriority()->create(); + + // Act + $models = $modelClass::query()->prioritized()->get(); + + // Assert + expect($models)->toHaveCount(4); + expect($models->first()->priority)->toBe(Priority::Urgent); + expect($models->last()->priority)->toBe(Priority::Low); +})->with('models'); diff --git a/tests/Unit/Models/TicketTest.php b/tests/Unit/Models/TicketTest.php index 088ba14..779b218 100644 --- a/tests/Unit/Models/TicketTest.php +++ b/tests/Unit/Models/TicketTest.php @@ -1,6 +1,6 @@ device_id)->not->toBeNull(); expect($ticket->title)->not->toBeEmpty(); expect($ticket->description)->not->toBeEmpty(); - expect($ticket->priority)->toBeInstanceOf(TicketPriority::class); + expect($ticket->priority)->toBeInstanceOf(Priority::class); expect($ticket->status)->toBeInstanceOf(TicketStatus::class); expect($ticket->due_date)->not->toBeNull(); }); -it('can create a ticket of priority', function () { - // Arrange - $ticket = Ticket::factory()->ofPriority(TicketPriority::High)->create(); - - // Assert - expect($ticket->priority)->toBe(TicketPriority::High); -}); - it('can create a ticket of status', function () { // Arrange $ticket = Ticket::factory()->ofStatus(TicketStatus::InProgress)->create(); @@ -46,7 +38,7 @@ $ticket->update([ 'title' => 'Updated Ticket Title', 'description' => 'Updated ticket description', - 'priority' => TicketPriority::High, + 'priority' => Priority::High, 'status' => TicketStatus::InProgress, 'due_date' => now()->addMonth(), ]); @@ -54,7 +46,7 @@ // Assert expect($ticket->title)->toBe('Updated Ticket Title'); expect($ticket->description)->toBe('Updated ticket description'); - expect($ticket->priority)->toBe(TicketPriority::High); + expect($ticket->priority)->toBe(Priority::High); expect($ticket->status)->toBe(TicketStatus::InProgress); expect($ticket->due_date->isFuture())->toBeTrue(); }); @@ -99,18 +91,6 @@ expect($ticket->orders)->toHaveCount(2); }); -it('can filter tickets by priority scope', function (TicketPriority $priority) { - // Arrange - Ticket::factory()->ofPriority($priority)->create(); - - // Act - $tickets = Ticket::ofPriority($priority)->get(); - - // Assert - expect($tickets->count())->toBe(1); - expect($tickets->first()->priority)->toBe($priority); -})->with(TicketPriority::cases()); - it('can filter tickets by status scope', function (TicketStatus $status) { // Arrange Ticket::factory()->ofStatus($status)->create(); From ded61877f32020061b23fac378e2414a5fffcfb2 Mon Sep 17 00:00:00 2001 From: Evren Ceyhan Date: Sat, 12 Jul 2025 21:39:31 +0200 Subject: [PATCH 09/11] Implement Contactable concern for Customer and User models --- app/Models/Concerns/Contactable.php | 65 +++++++++++++ app/Models/Customer.php | 3 +- app/Models/User.php | 3 +- database/factories/CustomerFactory.php | 23 +---- database/factories/UserFactory.php | 16 ++-- .../factories/states/HasContactableStates.php | 66 ++++++++++++++ database/seeders/CustomerSeeder.php | 4 +- .../Unit/Models/Concerns/ContactableTest.php | 91 +++++++++++++++++++ tests/Unit/Models/CustomerTest.php | 16 ---- tests/Unit/Models/UserTest.php | 8 -- 10 files changed, 237 insertions(+), 58 deletions(-) create mode 100644 app/Models/Concerns/Contactable.php create mode 100644 database/factories/states/HasContactableStates.php create mode 100644 tests/Unit/Models/Concerns/ContactableTest.php diff --git a/app/Models/Concerns/Contactable.php b/app/Models/Concerns/Contactable.php new file mode 100644 index 0000000..28edfb2 --- /dev/null +++ b/app/Models/Concerns/Contactable.php @@ -0,0 +1,65 @@ +{static::EMAIL}; + } + + /** + * Determine if the model has a phone number. + */ + public function isCallable(): bool + { + return (bool) $this->{static::PHONE}; + } + + /** + * Determine if the model can be contacted via email or phone. + */ + public function isContactable(): bool + { + return $this->isMailable() || $this->isCallable(); + } + + // SCOPES ////////////////////////////////////////////////////////////////////////////////////// + + /** + * Scope a query to only include models that have an email address. + */ + public function scopeMailable(Builder $query): void + { + $query->whereNotNull(static::EMAIL); + } + + /** + * Scope a query to only include models that have a phone number. + */ + public function scopeCallable(Builder $query): void + { + $query->whereNotNull(static::PHONE); + } + + /** + * Scope a query to only include models that are contactable. + */ + public function scopeContactable(Builder $query): void + { + $query->where(function (Builder $query) { + $query->whereNotNull(static::EMAIL) + ->orWhereNotNull(static::PHONE); + }); + } +} diff --git a/app/Models/Customer.php b/app/Models/Customer.php index 5b3fba3..1c6f83c 100644 --- a/app/Models/Customer.php +++ b/app/Models/Customer.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Models\Concerns\Contactable; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasMany; @@ -10,7 +11,7 @@ class Customer extends Model { /** @use HasFactory<\Database\Factories\CustomerFactory> */ - use HasFactory; + use Contactable, HasFactory; /** * The attributes that are mass assignable. diff --git a/app/Models/User.php b/app/Models/User.php index 5ea0832..b6a6d22 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -4,6 +4,7 @@ use App\Enums\UserRole; use App\Enums\UserStatus; +use App\Models\Concerns\Contactable; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\HasMany; @@ -13,7 +14,7 @@ class User extends Authenticatable { /** @use HasFactory<\Database\Factories\UserFactory> */ - use HasFactory, Notifiable; + use Contactable, HasFactory, Notifiable; /** * The model's default values for attributes. diff --git a/database/factories/CustomerFactory.php b/database/factories/CustomerFactory.php index a2defaf..3f14af5 100644 --- a/database/factories/CustomerFactory.php +++ b/database/factories/CustomerFactory.php @@ -2,6 +2,7 @@ namespace Database\Factories; +use Database\Factories\States\HasContactableStates; use Illuminate\Database\Eloquent\Factories\Factory; /** @@ -11,6 +12,8 @@ */ class CustomerFactory extends Factory { + use HasContactableStates; + public function definition(): array { return [ @@ -37,26 +40,6 @@ public function withoutCompany(): self ]); } - /** - * Indicate that the customer has no email. - */ - public function withoutEmail(): self - { - return $this->state(fn (array $attributes) => [ - 'email' => null, - ]); - } - - /** - * Indicate that the customer has no phone number. - */ - public function withoutPhone(): self - { - return $this->state(fn (array $attributes) => [ - 'phone' => null, - ]); - } - /** * Indicate that the customer has no address. */ diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index 9872f4d..f970b4e 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -4,6 +4,7 @@ use App\Enums\UserRole; use App\Enums\UserStatus; +use Database\Factories\States\HasContactableStates; use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Str; @@ -13,6 +14,11 @@ */ class UserFactory extends Factory { + use HasContactableStates { + mailable as private; + notMailable as private; + } + /** * The current password being used by the factory. */ @@ -39,16 +45,6 @@ public function definition(): array // STATES ////////////////////////////////////////////////////////////////////////////////////// - /** - * Indicate that the user has no phone number. - */ - public function withoutPhone(): self - { - return $this->state(fn (array $attributes) => [ - 'phone' => null, - ]); - } - /** * Indicate that the user has the specified role. */ diff --git a/database/factories/states/HasContactableStates.php b/database/factories/states/HasContactableStates.php new file mode 100644 index 0000000..0ebe261 --- /dev/null +++ b/database/factories/states/HasContactableStates.php @@ -0,0 +1,66 @@ +state(fn (array $attributes) => [ + static::EMAIL => fake()->unique()->safeEmail(), + ]); + } + + /** + * Indicate that the model has no email address. + */ + public function notMailable(): self + { + return $this->state(fn (array $attributes) => [ + static::EMAIL => null, + ]); + } + + /** + * Indicate that the model has a phone number. + */ + public function callable(): self + { + return $this->state(fn (array $attributes) => [ + static::PHONE => fake()->unique()->phoneNumber(), + ]); + } + + /** + * Indicate that the model has no phone number. + */ + public function notCallable(): self + { + return $this->state(fn (array $attributes) => [ + static::PHONE => null, + ]); + } + + /** + * Indicate that the model is contactable (has email or phone). + */ + public function contactable(): self + { + return $this->mailable()->callable(); + } + + /** + * Indicate that the model is not contactable (no email or phone). + */ + public function notContactable(): self + { + return $this->notMailable()->notCallable(); + } +} diff --git a/database/seeders/CustomerSeeder.php b/database/seeders/CustomerSeeder.php index 4e9dcaf..c912044 100644 --- a/database/seeders/CustomerSeeder.php +++ b/database/seeders/CustomerSeeder.php @@ -28,12 +28,12 @@ public function run(): void ]); // Create a customer without email - Customer::factory()->withoutEmail()->create([ + Customer::factory()->notMailable()->create([ 'created_at' => $startDate, ]); // Create a customer without phone - Customer::factory()->withoutPhone()->create([ + Customer::factory()->notCallable()->create([ 'created_at' => $startDate, ]); diff --git a/tests/Unit/Models/Concerns/ContactableTest.php b/tests/Unit/Models/Concerns/ContactableTest.php new file mode 100644 index 0000000..b3871a8 --- /dev/null +++ b/tests/Unit/Models/Concerns/ContactableTest.php @@ -0,0 +1,91 @@ + [User::class], + 'Customer' => [Customer::class], +]); + +dataset('optional_models', [ + // User email is not nullable, so we skip it for + // mailable tests and contactable which makes use of email + 'Customer' => [Customer::class], +]); + +it('can determine if model is mailable', function (string $modelClass) { + // Arrange + $mailableModel = $modelClass::factory()->mailable()->create(); + $notMailableModel = $modelClass::factory()->notMailable()->create(); + + // Assert + expect($mailableModel->isMailable())->toBeTrue(); + expect($mailableModel->email)->not->toBeNull(); + expect($notMailableModel->isMailable())->toBeFalse(); + expect($notMailableModel->email)->toBeNull(); +})->with('optional_models'); + +it('can determine if model is callable', function (string $modelClass) { + // Arrange + $callableModel = $modelClass::factory()->callable()->create(); + $notCallableModel = $modelClass::factory()->notCallable()->create(); + + // Assert + expect($callableModel->isCallable())->toBeTrue(); + expect($callableModel->phone)->not->toBeNull(); + expect($notCallableModel->isCallable())->toBeFalse(); + expect($notCallableModel->phone)->toBeNull(); +})->with('models'); + +it('can determine if model is contactable', function (string $modelClass) { + // Arrange + $contactableModel = $modelClass::factory()->contactable()->create(); + $notContactableModel = $modelClass::factory()->notContactable()->create(); + + // Assert + expect($contactableModel->isContactable())->toBeTrue(); + expect($notContactableModel->isContactable())->toBeFalse(); +})->with('optional_models'); + +it('can filter records by mailable scope', function (string $modelClass) { + // Arrange + $modelClass::factory(2)->mailable()->create(); + $modelClass::factory(1)->notMailable()->create(); + + // Act + $mailableModels = $modelClass::query()->mailable()->get(); + + // Assert + expect($mailableModels)->toHaveCount(2); + expect($mailableModels->first()->isMailable())->toBeTrue(); +})->with('optional_models'); + +it('can filter records by callable scope', function (string $modelClass) { + // Arrange + $modelClass::factory(2)->callable()->create(); + $modelClass::factory(1)->notCallable()->create(); + + // Act + $callableModels = $modelClass::query()->callable()->get(); + + // Assert + expect($callableModels)->toHaveCount(2); + expect($callableModels->first()->isCallable())->toBeTrue(); +})->with('models'); + +it('can filter records by contactable scope', function (string $modelClass) { + // Arrange + $modelClass::factory(2)->contactable()->create(); + $modelClass::factory(1)->notContactable()->create(); + + // Act + $contactableModels = $modelClass::query()->contactable()->get(); + + // Assert + expect($contactableModels)->toHaveCount(2); + expect($contactableModels->first()->isContactable())->toBeTrue(); +})->with('optional_models'); diff --git a/tests/Unit/Models/CustomerTest.php b/tests/Unit/Models/CustomerTest.php index 502fe7b..e03e783 100644 --- a/tests/Unit/Models/CustomerTest.php +++ b/tests/Unit/Models/CustomerTest.php @@ -16,22 +16,6 @@ expect($customer->address)->not->toBeEmpty(); }); -it('can create customer without email', function () { - // Act - $customer = Customer::factory()->withoutEmail()->create(); - - // Assert - expect($customer->email)->toBeNull(); -}); - -it('can create customer without phone', function () { - // Act - $customer = Customer::factory()->withoutPhone()->create(); - - // Assert - expect($customer->phone)->toBeNull(); -}); - it('can create customer without address', function () { // Act $customer = Customer::factory()->withoutAddress()->create(); diff --git a/tests/Unit/Models/UserTest.php b/tests/Unit/Models/UserTest.php index f62dd5b..52491aa 100644 --- a/tests/Unit/Models/UserTest.php +++ b/tests/Unit/Models/UserTest.php @@ -19,14 +19,6 @@ expect($user->status)->toBeInstanceOf(UserStatus::class); }); -it('can create user without phone number', function () { - // Act - $user = User::factory()->withoutPhone()->create(); - - // Assert - expect($user->phone)->toBeNull(); -}); - it('can create user of role', function (UserRole $role) { // Act $user = User::factory()->ofRole($role)->create(); From eb6a410468bc98b9072a1097e9f2e23d08870f9b Mon Sep 17 00:00:00 2001 From: Evren Ceyhan Date: Sat, 12 Jul 2025 23:01:38 +0200 Subject: [PATCH 10/11] Implement HasStatus concern for multiple models --- app/Models/Concerns/HasStatus.php | 38 ++++++++++++++++++ app/Models/Device.php | 17 +++----- app/Models/Invoice.php | 20 +++------- app/Models/Order.php | 20 +++------- app/Models/Task.php | 17 +++----- app/Models/Ticket.php | 20 +++------- app/Models/User.php | 17 +++----- database/factories/DeviceFactory.php | 13 +------ database/factories/InvoiceFactory.php | 13 +------ database/factories/OrderFactory.php | 15 +------ database/factories/TaskFactory.php | 13 +------ database/factories/TicketFactory.php | 15 +------ database/factories/UserFactory.php | 12 +----- database/factories/states/HasStatusStates.php | 20 ++++++++++ tests/Unit/Models/Concerns/HasStatusTest.php | 39 +++++++++++++++++++ tests/Unit/Models/DeviceTest.php | 20 ---------- tests/Unit/Models/InvoiceTest.php | 20 ---------- tests/Unit/Models/OrderTest.php | 20 ---------- tests/Unit/Models/TaskTest.php | 20 ---------- tests/Unit/Models/TicketTest.php | 20 ---------- tests/Unit/Models/UserTest.php | 20 ---------- 21 files changed, 145 insertions(+), 264 deletions(-) create mode 100644 app/Models/Concerns/HasStatus.php create mode 100644 database/factories/states/HasStatusStates.php create mode 100644 tests/Unit/Models/Concerns/HasStatusTest.php diff --git a/app/Models/Concerns/HasStatus.php b/app/Models/Concerns/HasStatus.php new file mode 100644 index 0000000..50d79f6 --- /dev/null +++ b/app/Models/Concerns/HasStatus.php @@ -0,0 +1,38 @@ +attributes[static::STATUS] ?? null; + + // Use the class of the default status + $this->casts[static::STATUS] = $defaultStatus::class; + } + + // SCOPES ////////////////////////////////////////////////////////////////////////////////////// + + /** + * Scope a query to only include users with the specified status. + * + * @param TStatus $status + */ + public function scopeOfStatus(Builder $query, BackedEnum $status): void + { + $query->where(self::STATUS, $status->value); + } +} diff --git a/app/Models/Device.php b/app/Models/Device.php index 7994914..dcb1e30 100644 --- a/app/Models/Device.php +++ b/app/Models/Device.php @@ -5,6 +5,7 @@ use App\Enums\DeviceStatus; use App\Enums\DeviceType; use App\Models\Concerns\HasProgress; +use App\Models\Concerns\HasStatus; use App\Models\Concerns\HasWarranty; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -14,8 +15,11 @@ class Device extends Model { - /** @use HasFactory<\Database\Factories\DeviceFactory> */ - use HasFactory, HasProgress, HasWarranty; + /** + * @use HasFactory<\Database\Factories\DeviceFactory> + * @use HasStatus<\App\Enums\DeviceStatus> + */ + use HasFactory, HasProgress, HasStatus, HasWarranty; /** * The model's default values for attributes. @@ -50,7 +54,6 @@ class Device extends Model protected $casts = [ 'purchase_date' => 'date', 'type' => DeviceType::class, - 'status' => DeviceStatus::class, ]; // RELATIONS /////////////////////////////////////////////////////////////////////////////////// @@ -80,12 +83,4 @@ public function scopeOfType(Builder $query, DeviceType $type): void { $query->where('type', $type->value); } - - /** - * Scope a query to only include devices of a given status. - */ - public function scopeOfStatus(Builder $query, DeviceStatus $status): void - { - $query->where('status', $status->value); - } } diff --git a/app/Models/Invoice.php b/app/Models/Invoice.php index 89b8e3c..b01e0c9 100644 --- a/app/Models/Invoice.php +++ b/app/Models/Invoice.php @@ -5,7 +5,7 @@ use App\Enums\InvoiceStatus; use App\Models\Concerns\HasDueDate; use App\Models\Concerns\HasProgress; -use Illuminate\Database\Eloquent\Builder; +use App\Models\Concerns\HasStatus; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -13,8 +13,11 @@ class Invoice extends Model { - /** @use HasFactory<\Database\Factories\InvoiceFactory> */ - use HasDueDate, HasFactory, HasProgress; + /** + * @use HasFactory<\Database\Factories\InvoiceFactory> + * @use HasStatus<\App\Enums\InvoiceStatus> + */ + use HasDueDate, HasFactory, HasProgress, HasStatus; /** * The model's default values for attributes. @@ -51,7 +54,6 @@ class Invoice extends Model 'discount_amount' => 'float', 'paid_amount' => 'float', 'refunded_amount' => 'float', - 'status' => InvoiceStatus::class, ]; // RELATIONS /////////////////////////////////////////////////////////////////////////////////// @@ -87,14 +89,4 @@ public function transactions(): HasMany { return $this->hasMany(Transaction::class); } - - // SCOPES ////////////////////////////////////////////////////////////////////////////////////// - - /** - * Scope a query to only include invoices with the specified status. - */ - public function scopeOfStatus(Builder $query, InvoiceStatus $status): void - { - $query->where('status', $status->value); - } } diff --git a/app/Models/Order.php b/app/Models/Order.php index b7fa20b..96c6867 100644 --- a/app/Models/Order.php +++ b/app/Models/Order.php @@ -6,14 +6,17 @@ use App\Models\Concerns\Billable; use App\Models\Concerns\HasApproval; use App\Models\Concerns\HasProgress; -use Illuminate\Database\Eloquent\Builder; +use App\Models\Concerns\HasStatus; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; class Order extends Model { - /** @use HasFactory<\Database\Factories\OrderFactory> */ - use Billable, HasApproval, HasFactory, HasProgress; + /** + * @use HasFactory<\Database\Factories\OrderFactory> + * @use HasStatus<\App\Enums\OrderStatus> + */ + use Billable, HasApproval, HasFactory, HasProgress, HasStatus; /** * The model's default values for attributes. @@ -49,7 +52,6 @@ class Order extends Model protected $casts = [ 'quantity' => 'integer', 'cost' => 'float', - 'status' => OrderStatus::class, ]; // RELATIONS /////////////////////////////////////////////////////////////////////////////////// @@ -61,14 +63,4 @@ public function ticket() { return $this->belongsTo(Ticket::class); } - - // SCOPES ////////////////////////////////////////////////////////////////////////////////////// - - /** - * Scope a query to only include orders of a given status. - */ - public function scopeOfStatus(Builder $query, OrderStatus $status): void - { - $query->where('status', $status->value); - } } diff --git a/app/Models/Task.php b/app/Models/Task.php index ad0cdb6..82fe9cc 100644 --- a/app/Models/Task.php +++ b/app/Models/Task.php @@ -7,14 +7,18 @@ use App\Models\Concerns\Billable; use App\Models\Concerns\HasApproval; use App\Models\Concerns\HasProgress; +use App\Models\Concerns\HasStatus; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; class Task extends Model { - /** @use HasFactory<\Database\Factories\TaskFactory> */ - use Billable, HasApproval, HasFactory, HasProgress; + /** + * @use HasFactory<\Database\Factories\TaskFactory> + * @use HasStatus<\App\Enums\TaskStatus> + */ + use Billable, HasApproval, HasFactory, HasProgress, HasStatus; /** * The model's default values for attributes. @@ -48,7 +52,6 @@ class Task extends Model protected $casts = [ 'cost' => 'float', 'type' => TaskType::class, - 'status' => TaskStatus::class, ]; // RELATIONS /////////////////////////////////////////////////////////////////////////////////// @@ -70,12 +73,4 @@ public function scopeOfType(Builder $query, TaskType $type): void { $query->where('type', $type->value); } - - /** - * Scope a query to only include tasks of a given status. - */ - public function scopeOfStatus(Builder $query, TaskStatus $status): void - { - $query->where('status', $status->value); - } } diff --git a/app/Models/Ticket.php b/app/Models/Ticket.php index ccd8830..fa63f7f 100644 --- a/app/Models/Ticket.php +++ b/app/Models/Ticket.php @@ -7,7 +7,7 @@ use App\Models\Concerns\HasDueDate; use App\Models\Concerns\HasPriority; use App\Models\Concerns\HasProgress; -use Illuminate\Database\Eloquent\Builder; +use App\Models\Concerns\HasStatus; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -16,8 +16,11 @@ class Ticket extends Model { - /** @use HasFactory<\Database\Factories\TicketFactory> */ - use Assignable, HasDueDate, HasFactory, HasPriority, HasProgress; + /** + * @use HasFactory<\Database\Factories\TicketFactory> + * @use HasStatus<\App\Enums\TicketStatus> + */ + use Assignable, HasDueDate, HasFactory, HasPriority, HasProgress, HasStatus; /** * The model's default values for attributes. @@ -47,7 +50,6 @@ class Ticket extends Model * @var array */ protected $casts = [ - 'status' => TicketStatus::class, ]; // RELATIONS /////////////////////////////////////////////////////////////////////////////////// @@ -91,14 +93,4 @@ public function invoice(): HasOne { return $this->hasOne(Invoice::class); } - - // SCOPES ////////////////////////////////////////////////////////////////////////////////////// - - /** - * Scope a query to only include tickets with the specified status. - */ - public function scopeOfStatus(Builder $query, TicketStatus $status): void - { - $query->where('status', $status->value); - } } diff --git a/app/Models/User.php b/app/Models/User.php index b6a6d22..e8534b9 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -5,6 +5,7 @@ use App\Enums\UserRole; use App\Enums\UserStatus; use App\Models\Concerns\Contactable; +use App\Models\Concerns\HasStatus; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\HasMany; @@ -13,8 +14,11 @@ class User extends Authenticatable { - /** @use HasFactory<\Database\Factories\UserFactory> */ - use Contactable, HasFactory, Notifiable; + /** + * @use HasFactory<\Database\Factories\UserFactory> + * @use HasStatus<\App\Enums\UserStatus> + */ + use Contactable, HasFactory, HasStatus, Notifiable; /** * The model's default values for attributes. @@ -59,7 +63,6 @@ class User extends Authenticatable 'email_verified_at' => 'datetime', 'password' => 'hashed', 'role' => UserRole::class, - 'status' => UserStatus::class, ]; // RELATIONS /////////////////////////////////////////////////////////////////////////////////// @@ -82,14 +85,6 @@ public function scopeOfRole(Builder $query, UserRole $role): void $query->where('role', $role->value); } - /** - * Scope a query to only include users with the specified status. - */ - public function scopeOfStatus(Builder $query, UserStatus $status): void - { - $query->where('status', $status->value); - } - // METHODS ///////////////////////////////////////////////////////////////////////////////////// /** diff --git a/database/factories/DeviceFactory.php b/database/factories/DeviceFactory.php index fed296e..fe3bd87 100644 --- a/database/factories/DeviceFactory.php +++ b/database/factories/DeviceFactory.php @@ -6,6 +6,7 @@ use App\Enums\DeviceType; use App\Models\Customer; use Database\Factories\States\HasProgressStates; +use Database\Factories\States\HasStatusStates; use Database\Factories\States\HasWarrantyStates; use Illuminate\Database\Eloquent\Factories\Factory; @@ -16,7 +17,7 @@ */ class DeviceFactory extends Factory { - use HasProgressStates, HasWarrantyStates; + use HasProgressStates, HasStatusStates, HasWarrantyStates; const VERSIONS = [ 'iMac' => ['21"', '27"'], @@ -150,14 +151,4 @@ public function ofType(DeviceType $type): self 'type' => $type, ]); } - - /** - * Indicate that the device is of the given status. - */ - public function ofStatus(DeviceStatus $status): self - { - return $this->state(fn (array $attributes) => [ - 'status' => $status, - ]); - } } diff --git a/database/factories/InvoiceFactory.php b/database/factories/InvoiceFactory.php index 71c6dbd..4fd293f 100644 --- a/database/factories/InvoiceFactory.php +++ b/database/factories/InvoiceFactory.php @@ -6,6 +6,7 @@ use App\Models\Ticket; use Database\Factories\States\HasDueDateStates; use Database\Factories\States\HasProgressStates; +use Database\Factories\States\HasStatusStates; use Illuminate\Database\Eloquent\Factories\Factory; /** @@ -15,7 +16,7 @@ */ class InvoiceFactory extends Factory { - use HasDueDateStates, HasProgressStates; + use HasDueDateStates, HasProgressStates, HasStatusStates; /** * Define the model's default state. @@ -66,16 +67,6 @@ public function forTicket(Ticket $ticket): static // STATES ////////////////////////////////////////////////////////////////////////////////////// - /** - * Indicate that the invoice is of a specified status. - */ - public function ofStatus(InvoiceStatus $status): self - { - return $this->state(fn (array $attributes) => [ - 'status' => $status, - ]); - } - /** * Indicate that the invoice has been paid. */ diff --git a/database/factories/OrderFactory.php b/database/factories/OrderFactory.php index fc35765..800643f 100644 --- a/database/factories/OrderFactory.php +++ b/database/factories/OrderFactory.php @@ -7,6 +7,7 @@ use Database\Factories\States\HasApprovalStates; use Database\Factories\States\HasBillableStates; use Database\Factories\States\HasProgressStates; +use Database\Factories\States\HasStatusStates; use Illuminate\Database\Eloquent\Factories\Factory; /** @@ -14,7 +15,7 @@ */ class OrderFactory extends Factory { - use HasApprovalStates, HasBillableStates, HasProgressStates; + use HasApprovalStates, HasBillableStates, HasProgressStates, HasStatusStates; const PARTS = [ 'Dell XPS 13 Battery', @@ -69,16 +70,4 @@ public function forTicket(Ticket $ticket): self 'created_at' => $ticket->created_at, ]); } - - // STATES ////////////////////////////////////////////////////////////////////////////////////// - - /** - * Indicate that the order is of a specified status. - */ - public function ofStatus(OrderStatus $status): self - { - return $this->state(fn (array $attributes) => [ - 'status' => $status, - ]); - } } diff --git a/database/factories/TaskFactory.php b/database/factories/TaskFactory.php index fc780db..6e51648 100644 --- a/database/factories/TaskFactory.php +++ b/database/factories/TaskFactory.php @@ -8,6 +8,7 @@ use Database\Factories\States\HasApprovalStates; use Database\Factories\States\HasBillableStates; use Database\Factories\States\HasProgressStates; +use Database\Factories\States\HasStatusStates; use Illuminate\Database\Eloquent\Factories\Factory; /** @@ -15,7 +16,7 @@ */ class TaskFactory extends Factory { - use HasApprovalStates, HasBillableStates, HasProgressStates; + use HasApprovalStates, HasBillableStates, HasProgressStates, HasStatusStates; /** * Define the model's default state. @@ -59,14 +60,4 @@ public function ofType(TaskType $type): self 'type' => $type, ]); } - - /** - * Indicate that the task is of a specific status. - */ - public function ofStatus(TaskStatus $status): self - { - return $this->state(fn (array $attributes) => [ - 'status' => $status, - ]); - } } diff --git a/database/factories/TicketFactory.php b/database/factories/TicketFactory.php index 34fa1e8..7e625d2 100644 --- a/database/factories/TicketFactory.php +++ b/database/factories/TicketFactory.php @@ -9,6 +9,7 @@ use Database\Factories\States\HasDueDateStates; use Database\Factories\States\HasPriorityStates; use Database\Factories\States\HasProgressStates; +use Database\Factories\States\HasStatusStates; use Illuminate\Database\Eloquent\Factories\Factory; /** @@ -19,7 +20,7 @@ */ class TicketFactory extends Factory { - use HasAssignableStates, HasDueDateStates, HasPriorityStates, HasProgressStates; + use HasAssignableStates, HasDueDateStates, HasPriorityStates, HasProgressStates, HasStatusStates; /** * Define the model's default state. @@ -51,16 +52,4 @@ public function forDevice(Device $device): self 'due_date' => $device->created_at->addWeek(), ]); } - - // STATES ////////////////////////////////////////////////////////////////////////////////////// - - /** - * Indicate that the ticket is of the given status. - */ - public function ofStatus(TicketStatus $status): self - { - return $this->state(fn (array $attributes) => [ - 'status' => $status, - ]); - } } diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index f970b4e..47d8830 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -5,6 +5,7 @@ use App\Enums\UserRole; use App\Enums\UserStatus; use Database\Factories\States\HasContactableStates; +use Database\Factories\States\HasStatusStates; use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Str; @@ -18,6 +19,7 @@ class UserFactory extends Factory mailable as private; notMailable as private; } + use HasStatusStates; /** * The current password being used by the factory. @@ -71,16 +73,6 @@ public function manager(): self return $this->ofRole(UserRole::Manager); } - /** - * Indicate that the user has the specified status. - */ - public function ofStatus(UserStatus $status): self - { - return $this->state(fn (array $attributes) => [ - 'status' => $status, - ]); - } - /** * Indicate that the user's email address is unverified. */ diff --git a/database/factories/states/HasStatusStates.php b/database/factories/states/HasStatusStates.php new file mode 100644 index 0000000..457c273 --- /dev/null +++ b/database/factories/states/HasStatusStates.php @@ -0,0 +1,20 @@ +state(fn (array $attributes) => [ + static::STATUS => $status, + ]); + } +} diff --git a/tests/Unit/Models/Concerns/HasStatusTest.php b/tests/Unit/Models/Concerns/HasStatusTest.php new file mode 100644 index 0000000..bf9c7c4 --- /dev/null +++ b/tests/Unit/Models/Concerns/HasStatusTest.php @@ -0,0 +1,39 @@ + [User::class, UserStatus::Active, UserStatus::Terminated], + 'Device' => [Device::class, DeviceStatus::Received, DeviceStatus::OnHold], + 'Ticket' => [Ticket::class, TicketStatus::New, TicketStatus::InProgress], + 'Task' => [Task::class, TaskStatus::New, TaskStatus::Cancelled], + 'Order' => [Order::class, OrderStatus::New, OrderStatus::Cancelled], + 'Invoice' => [Invoice::class, InvoiceStatus::Draft, InvoiceStatus::Sent], +]); + +it('can filter records by status scope', function (string $modelClass, BackedEnum $status, BackedEnum $otherStatus) { + // Arrange + $modelClass::factory(2)->ofStatus($status)->create(); + $modelClass::factory(1)->ofStatus($otherStatus)->create(); + + // Act + $results = $modelClass::query()->ofStatus($status)->get(); + + // Assert + expect($results)->toHaveCount(2); + expect($results->first()->status)->toBe($status); +})->with('models'); diff --git a/tests/Unit/Models/DeviceTest.php b/tests/Unit/Models/DeviceTest.php index f685752..47a9eb1 100644 --- a/tests/Unit/Models/DeviceTest.php +++ b/tests/Unit/Models/DeviceTest.php @@ -52,14 +52,6 @@ expect($device->type)->toBe($type); })->with(DeviceType::cases()); -it('can create a device of status', function (DeviceStatus $status) { - // Arrange - $device = Device::factory()->ofStatus($status)->create(); - - // Assert - expect($device->status)->toBe($status); -})->with(DeviceStatus::cases()); - it('can update a device', function () { // Arrange $device = Device::factory()->create(); @@ -122,15 +114,3 @@ expect($devices)->toHaveCount(1); expect($devices->first()->type)->toBe($type); })->with(DeviceType::cases()); - -it('can filter devices by status scope', function (DeviceStatus $status) { - // Arrange - Device::factory()->ofStatus($status)->create(); - - // Act - $devices = Device::ofStatus($status)->get(); - - // Assert - expect($devices)->toHaveCount(1); - expect($devices->first()->status)->toBe($status); -})->with(DeviceStatus::cases()); diff --git a/tests/Unit/Models/InvoiceTest.php b/tests/Unit/Models/InvoiceTest.php index b4a9218..64e3598 100644 --- a/tests/Unit/Models/InvoiceTest.php +++ b/tests/Unit/Models/InvoiceTest.php @@ -51,14 +51,6 @@ expect($invoice->refunded_amount)->toBe($invoice->total); }); -it('can create an invoice of status', function (InvoiceStatus $status) { - // Arrange & Act - $invoice = Invoice::factory()->ofStatus($status)->create(); - - // Assert - expect($invoice->status)->toBe($status); -})->with(InvoiceStatus::cases()); - it('can update an invoice', function () { // Arrange $invoice = Invoice::factory()->create(); @@ -119,15 +111,3 @@ expect($invoice->ticket_id)->toBe($ticket->id); expect($invoice->customer->id)->toBe($customer->id); }); - -it('can filter invoices by status scope', function (InvoiceStatus $status) { - // Arrange - Invoice::factory(2)->ofStatus($status)->create(); - - // Act - $invoices = Invoice::query()->ofStatus($status)->get(); - - // Assert - expect($invoices)->toHaveCount(2); - expect($invoices->first()->status)->toBe($status); -})->with(InvoiceStatus::cases()); diff --git a/tests/Unit/Models/OrderTest.php b/tests/Unit/Models/OrderTest.php index 68b09d1..d52f158 100644 --- a/tests/Unit/Models/OrderTest.php +++ b/tests/Unit/Models/OrderTest.php @@ -33,14 +33,6 @@ expect($order->ticket->id)->toBe($ticket->id); }); -it('can create an order of status', function (OrderStatus $status) { - // Arrange & Act - $order = Order::factory()->ofStatus($status)->create(); - - // Assert - expect($order->status)->toBe($status); -})->with(OrderStatus::cases()); - it('can update an order', function () { // Arrange $order = Order::factory()->create(); @@ -88,15 +80,3 @@ expect($order->ticket)->toBeInstanceOf(Ticket::class); expect($order->ticket->id)->toBe($ticket->id); }); - -it('can filter orders by status scope', function (OrderStatus $status) { - // Arrange - Order::factory()->ofStatus($status)->create(); - - // Act - $orders = Order::query()->ofStatus($status)->get(); - - // Assert - expect($orders)->toHaveCount(1); - expect($orders->first()->status)->toBe($status); -})->with(OrderStatus::cases()); diff --git a/tests/Unit/Models/TaskTest.php b/tests/Unit/Models/TaskTest.php index 4396b2f..0c82fd4 100644 --- a/tests/Unit/Models/TaskTest.php +++ b/tests/Unit/Models/TaskTest.php @@ -42,14 +42,6 @@ expect($task->type)->toBe($type); })->with(TaskType::cases()); -it('can create a task of status', function (TaskStatus $status) { - // Arrange & Act - $task = Task::factory()->ofStatus($status)->create(); - - // Assert - expect($task->status)->toBe($status); -})->with(TaskStatus::cases()); - it('can update a task', function () { // Arrange $task = Task::factory()->create(); @@ -103,15 +95,3 @@ expect($tasks)->toHaveCount(1); expect($tasks->first()->type)->toBe($type); })->with(TaskType::cases()); - -it('can filter tasks by status scope', function (TaskStatus $status) { - // Arrange - Task::factory()->ofStatus($status)->create(); - - // Act - $tasks = Task::query()->ofStatus($status)->get(); - - // Assert - expect($tasks)->tohavecount(1); - expect($tasks->first()->status)->toBe($status); -})->with(TaskStatus::cases()); diff --git a/tests/Unit/Models/TicketTest.php b/tests/Unit/Models/TicketTest.php index 779b218..01b8fb8 100644 --- a/tests/Unit/Models/TicketTest.php +++ b/tests/Unit/Models/TicketTest.php @@ -22,14 +22,6 @@ expect($ticket->due_date)->not->toBeNull(); }); -it('can create a ticket of status', function () { - // Arrange - $ticket = Ticket::factory()->ofStatus(TicketStatus::InProgress)->create(); - - // Assert - expect($ticket->status)->toBe(TicketStatus::InProgress); -}); - it('can update a ticket', function () { // Arrange $ticket = Ticket::factory()->create(); @@ -90,15 +82,3 @@ expect($ticket->orders)->toHaveCount(2); }); - -it('can filter tickets by status scope', function (TicketStatus $status) { - // Arrange - Ticket::factory()->ofStatus($status)->create(); - - // Act - $tickets = Ticket::ofStatus($status)->get(); - - // Assert - expect($tickets->count())->toBe(1); - expect($tickets->first()->status)->toBe($status); -})->with(TicketStatus::cases()); diff --git a/tests/Unit/Models/UserTest.php b/tests/Unit/Models/UserTest.php index 52491aa..50c4359 100644 --- a/tests/Unit/Models/UserTest.php +++ b/tests/Unit/Models/UserTest.php @@ -27,14 +27,6 @@ expect($user->role)->toBe($role); })->with(UserRole::cases()); -it('can create user of status', function (UserStatus $status) { - // Act - $user = User::factory()->ofStatus($status)->create(); - - // Assert - expect($user->status)->toBe($status); -})->with(UserStatus::cases()); - it('can create user unverified', function () { // Act $user = User::factory()->unverified()->create(); @@ -106,15 +98,3 @@ expect($activeUser->isActive())->toBeTrue(); expect($suspendedUser->isActive())->toBeFalse(); }); - -it('can filter users by status scope', function (UserStatus $status) { - // Arrange - User::factory()->ofStatus($status)->create(); - - // Act - $users = User::ofStatus($status); - - // Assert - expect($users->count())->toBe(1); - expect($users->first()->status)->toBe($status); -})->with(UserStatus::cases()); From 6eed6a1d42642d5580a2fa0cd056ab703418914d Mon Sep 17 00:00:00 2001 From: Evren Ceyhan Date: Sat, 12 Jul 2025 23:17:47 +0200 Subject: [PATCH 11/11] Implement HasType concern for multiple models --- app/Models/Concerns/HasType.php | 38 +++++++++++++++++++++ app/Models/Device.php | 16 ++------- app/Models/Task.php | 16 ++------- app/Models/Transaction.php | 17 ++++----- database/factories/DeviceFactory.php | 13 ++----- database/factories/TaskFactory.php | 15 ++------ database/factories/TransactionFactory.php | 3 ++ database/factories/states/HasTypeStates.php | 20 +++++++++++ tests/Unit/Models/Concerns/HasTypeTest.php | 30 ++++++++++++++++ tests/Unit/Models/DeviceTest.php | 20 ----------- tests/Unit/Models/TaskTest.php | 20 ----------- tests/Unit/Models/TransactionTest.php | 20 ----------- 12 files changed, 107 insertions(+), 121 deletions(-) create mode 100644 app/Models/Concerns/HasType.php create mode 100644 database/factories/states/HasTypeStates.php create mode 100644 tests/Unit/Models/Concerns/HasTypeTest.php diff --git a/app/Models/Concerns/HasType.php b/app/Models/Concerns/HasType.php new file mode 100644 index 0000000..10b691d --- /dev/null +++ b/app/Models/Concerns/HasType.php @@ -0,0 +1,38 @@ +attributes[static::TYPE]; + + // Use the class of the default type + $this->casts[static::TYPE] = $defaultType::class; + } + + // SCOPES ////////////////////////////////////////////////////////////////////////////////////// + + /** + * Scope a query to only include records with the specified type. + * + * @param TType $type + */ + public function scopeOfType(Builder $query, BackedEnum $type): void + { + $query->where(self::TYPE, $type->value); + } +} diff --git a/app/Models/Device.php b/app/Models/Device.php index dcb1e30..a1f3b0c 100644 --- a/app/Models/Device.php +++ b/app/Models/Device.php @@ -6,8 +6,8 @@ use App\Enums\DeviceType; use App\Models\Concerns\HasProgress; use App\Models\Concerns\HasStatus; +use App\Models\Concerns\HasType; use App\Models\Concerns\HasWarranty; -use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -18,8 +18,9 @@ class Device extends Model /** * @use HasFactory<\Database\Factories\DeviceFactory> * @use HasStatus<\App\Enums\DeviceStatus> + * @use HasType<\App\Enums\DeviceType> */ - use HasFactory, HasProgress, HasStatus, HasWarranty; + use HasFactory, HasProgress, HasStatus, HasType, HasWarranty; /** * The model's default values for attributes. @@ -53,7 +54,6 @@ class Device extends Model */ protected $casts = [ 'purchase_date' => 'date', - 'type' => DeviceType::class, ]; // RELATIONS /////////////////////////////////////////////////////////////////////////////////// @@ -73,14 +73,4 @@ public function tickets(): HasMany { return $this->hasMany(Ticket::class); } - - // SCOPES ////////////////////////////////////////////////////////////////////////////////////// - - /** - * Scope a query to only include devices of a given type. - */ - public function scopeOfType(Builder $query, DeviceType $type): void - { - $query->where('type', $type->value); - } } diff --git a/app/Models/Task.php b/app/Models/Task.php index 82fe9cc..a38c1ad 100644 --- a/app/Models/Task.php +++ b/app/Models/Task.php @@ -8,7 +8,7 @@ use App\Models\Concerns\HasApproval; use App\Models\Concerns\HasProgress; use App\Models\Concerns\HasStatus; -use Illuminate\Database\Eloquent\Builder; +use App\Models\Concerns\HasType; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -17,8 +17,9 @@ class Task extends Model /** * @use HasFactory<\Database\Factories\TaskFactory> * @use HasStatus<\App\Enums\TaskStatus> + * @use HasType<\App\Enums\TaskType> */ - use Billable, HasApproval, HasFactory, HasProgress, HasStatus; + use Billable, HasApproval, HasFactory, HasProgress, HasStatus, HasType; /** * The model's default values for attributes. @@ -51,7 +52,6 @@ class Task extends Model */ protected $casts = [ 'cost' => 'float', - 'type' => TaskType::class, ]; // RELATIONS /////////////////////////////////////////////////////////////////////////////////// @@ -63,14 +63,4 @@ public function ticket() { return $this->belongsTo(Ticket::class); } - - // SCOPES ////////////////////////////////////////////////////////////////////////////////////// - - /** - * Scope a query to only include tasks of a given type. - */ - public function scopeOfType(Builder $query, TaskType $type): void - { - $query->where('type', $type->value); - } } diff --git a/app/Models/Transaction.php b/app/Models/Transaction.php index 77d0a20..7cbcb50 100644 --- a/app/Models/Transaction.php +++ b/app/Models/Transaction.php @@ -4,6 +4,7 @@ use App\Enums\TransactionMethod; use App\Enums\TransactionType; +use App\Models\Concerns\HasType; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -11,8 +12,11 @@ class Transaction extends Model { - /** @use HasFactory<\Database\Factories\TransactionFactory> */ - use HasFactory; + /** + * @use HasFactory<\Database\Factories\TransactionFactory> + * @use HasType<\App\Enums\TransactionType> + */ + use HasFactory, HasType; /** * The model's default values for attributes. @@ -44,7 +48,6 @@ class Transaction extends Model protected $casts = [ 'amount' => 'float', 'method' => TransactionMethod::class, - 'type' => TransactionType::class, ]; // RELATIONS /////////////////////////////////////////////////////////////////////////////////// @@ -66,12 +69,4 @@ public function scopeOfMethod(Builder $query, TransactionMethod $method): void { $query->where('method', $method->value); } - - /** - * Scope a query to only include transactions of a given type. - */ - public function scopeOfType(Builder $query, TransactionType $type): void - { - $query->where('type', $type->value); - } } diff --git a/database/factories/DeviceFactory.php b/database/factories/DeviceFactory.php index fe3bd87..81cb429 100644 --- a/database/factories/DeviceFactory.php +++ b/database/factories/DeviceFactory.php @@ -7,6 +7,7 @@ use App\Models\Customer; use Database\Factories\States\HasProgressStates; use Database\Factories\States\HasStatusStates; +use Database\Factories\States\HasTypeStates; use Database\Factories\States\HasWarrantyStates; use Illuminate\Database\Eloquent\Factories\Factory; @@ -17,7 +18,7 @@ */ class DeviceFactory extends Factory { - use HasProgressStates, HasStatusStates, HasWarrantyStates; + use HasProgressStates, HasStatusStates, HasTypeStates, HasWarrantyStates; const VERSIONS = [ 'iMac' => ['21"', '27"'], @@ -141,14 +142,4 @@ public function withoutPurchaseDate(): self 'warranty_expire_date' => null, ]); } - - /** - * Indicate that the device is of the given type. - */ - public function ofType(DeviceType $type): self - { - return $this->state(fn (array $attributes) => [ - 'type' => $type, - ]); - } } diff --git a/database/factories/TaskFactory.php b/database/factories/TaskFactory.php index 6e51648..e00b679 100644 --- a/database/factories/TaskFactory.php +++ b/database/factories/TaskFactory.php @@ -9,6 +9,7 @@ use Database\Factories\States\HasBillableStates; use Database\Factories\States\HasProgressStates; use Database\Factories\States\HasStatusStates; +use Database\Factories\States\HasTypeStates; use Illuminate\Database\Eloquent\Factories\Factory; /** @@ -16,7 +17,7 @@ */ class TaskFactory extends Factory { - use HasApprovalStates, HasBillableStates, HasProgressStates, HasStatusStates; + use HasApprovalStates, HasBillableStates, HasProgressStates, HasStatusStates, HasTypeStates; /** * Define the model's default state. @@ -48,16 +49,4 @@ public function forTicket(Ticket $ticket): self 'created_at' => $ticket->created_at, ]); } - - // STATES ////////////////////////////////////////////////////////////////////////////////////// - - /** - * Indicate that the task is of a specific type. - */ - public function ofType(TaskType $type): self - { - return $this->state(fn (array $attributes) => [ - 'type' => $type, - ]); - } } diff --git a/database/factories/TransactionFactory.php b/database/factories/TransactionFactory.php index ba561d1..f5a72e2 100644 --- a/database/factories/TransactionFactory.php +++ b/database/factories/TransactionFactory.php @@ -5,6 +5,7 @@ use App\Enums\TransactionMethod; use App\Enums\TransactionType; use App\Models\Invoice; +use Database\Factories\States\HasTypeStates; use Illuminate\Database\Eloquent\Factories\Factory; /** @@ -12,6 +13,8 @@ */ class TransactionFactory extends Factory { + use HasTypeStates; + /** * Define the model's default state. * diff --git a/database/factories/states/HasTypeStates.php b/database/factories/states/HasTypeStates.php new file mode 100644 index 0000000..2729970 --- /dev/null +++ b/database/factories/states/HasTypeStates.php @@ -0,0 +1,20 @@ +state(fn (array $attributes) => [ + static::TYPE => $type, + ]); + } +} diff --git a/tests/Unit/Models/Concerns/HasTypeTest.php b/tests/Unit/Models/Concerns/HasTypeTest.php new file mode 100644 index 0000000..f9ba926 --- /dev/null +++ b/tests/Unit/Models/Concerns/HasTypeTest.php @@ -0,0 +1,30 @@ + [Device::class, DeviceType::Phone, DeviceType::Laptop], + 'Task' => [Task::class, TaskType::Repair, TaskType::Maintenance], + 'Transaction' => [Transaction::class, TransactionType::Payment, TransactionType::Refund], +]); + +it('can filter records by type scope', function (string $modelClass, BackedEnum $type, BackedEnum $otherType) { + // Arrange + $modelClass::factory(2)->ofType($type)->create(); + $modelClass::factory(1)->ofType($otherType)->create(); + + // Act + $results = $modelClass::query()->ofType($type)->get(); + + // Assert + expect($results)->toHaveCount(2); + expect($results->first()->type)->toBe($type); +})->with('models'); diff --git a/tests/Unit/Models/DeviceTest.php b/tests/Unit/Models/DeviceTest.php index 47a9eb1..b3400bc 100644 --- a/tests/Unit/Models/DeviceTest.php +++ b/tests/Unit/Models/DeviceTest.php @@ -44,14 +44,6 @@ expect($device->warranty_expire_date)->toBeNull(); }); -it('can create a device of type', function (DeviceType $type) { - // Arrange - $device = Device::factory()->ofType($type)->create(); - - // Assert - expect($device->type)->toBe($type); -})->with(DeviceType::cases()); - it('can update a device', function () { // Arrange $device = Device::factory()->create(); @@ -102,15 +94,3 @@ // Assert expect($device->customer->id)->toBe($customer->id); }); - -it('can filter devices by type scope', function (DeviceType $type) { - // Arrange - Device::factory()->ofType($type)->create(); - - // Act - $devices = Device::ofType($type)->get(); - - // Assert - expect($devices)->toHaveCount(1); - expect($devices->first()->type)->toBe($type); -})->with(DeviceType::cases()); diff --git a/tests/Unit/Models/TaskTest.php b/tests/Unit/Models/TaskTest.php index 0c82fd4..e1bec44 100644 --- a/tests/Unit/Models/TaskTest.php +++ b/tests/Unit/Models/TaskTest.php @@ -34,14 +34,6 @@ expect($task->ticket->id)->toBe($ticket->id); }); -it('can create a task of type', function (TaskType $type) { - // Arrange & Act - $task = Task::factory()->ofType($type)->create(); - - // Assert - expect($task->type)->toBe($type); -})->with(TaskType::cases()); - it('can update a task', function () { // Arrange $task = Task::factory()->create(); @@ -83,15 +75,3 @@ expect($task->ticket)->toBeInstanceOf(Ticket::class); expect($task->ticket->id)->toBe($ticket->id); }); - -it('can filter tasks by type scope', function (TaskType $type) { - // Arrange - Task::factory()->ofType($type)->create(); - - // Act - $tasks = Task::query()->ofType($type)->get(); - - // Assert - expect($tasks)->toHaveCount(1); - expect($tasks->first()->type)->toBe($type); -})->with(TaskType::cases()); diff --git a/tests/Unit/Models/TransactionTest.php b/tests/Unit/Models/TransactionTest.php index 5d89e21..dd86956 100644 --- a/tests/Unit/Models/TransactionTest.php +++ b/tests/Unit/Models/TransactionTest.php @@ -46,14 +46,6 @@ expect($transaction->method)->toBe($method); })->with(TransactionMethod::cases()); -it('can create a transaction of type', function (TransactionType $type) { - // Arrange & Act - $transaction = Transaction::factory()->ofType($type)->create(); - - // Assert - expect($transaction->type)->toBe($type); -})->with(TransactionType::cases()); - it('can update a transaction', function () { // Arrange $transaction = Transaction::factory()->create(); @@ -105,15 +97,3 @@ expect($transactions)->toHaveCount(1); expect($transactions->first()->method)->toBe($method); })->with(TransactionMethod::cases()); - -it('can filter transactions by type scope', function (TransactionType $type) { - // Arrange - Transaction::factory()->ofType($type)->create(); - - // Act - $transactions = Transaction::ofType($type)->get(); - - // Assert - expect($transactions)->toHaveCount(1); - expect($transactions->first()->type)->toBe($type); -})->with(TransactionType::cases());