diff --git a/UPGRADE.md b/UPGRADE.md index 99c6cb1b..685984d4 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -9,7 +9,7 @@ The fastest way to upgrade is to run the following commands from your repos root * cp $ATHENIA_REPO/.env.example ./ * rsync -arv $ATHENIA_REPO/dockerfiles ./ * rsync -arv $ATHENIA_REPO/extras ./ -* rsync -arv $ATHENIA_REPO/code ./ --exclude vendor --exclude storage --exclude '.env' +* rsync -arv $ATHENIA_REPO/code ./ --exclude vendor --exclude storage --exclude '.env' --exclude code/app/Providers After that, you always want to make sure you inspect all changes, and you still want to go through the change log to check for moved files and deleted files, as rsync cannot check for deleted files, since it would delete any files created for the child application. diff --git a/code/app/Athenia/Contracts/Models/CanBeAggregatedContract.php b/code/app/Athenia/Contracts/Models/CanBeAggregatedContract.php new file mode 100644 index 00000000..f5944a7e --- /dev/null +++ b/code/app/Athenia/Contracts/Models/CanBeAggregatedContract.php @@ -0,0 +1,20 @@ +statistic; + } +} \ No newline at end of file diff --git a/code/app/Athenia/Events/Statistics/StatisticDeletedEvent.php b/code/app/Athenia/Events/Statistics/StatisticDeletedEvent.php new file mode 100644 index 00000000..78f7d5c2 --- /dev/null +++ b/code/app/Athenia/Events/Statistics/StatisticDeletedEvent.php @@ -0,0 +1,19 @@ +statistic; + } +} \ No newline at end of file diff --git a/code/app/Athenia/Events/Statistics/StatisticUpdatedEvent.php b/code/app/Athenia/Events/Statistics/StatisticUpdatedEvent.php new file mode 100644 index 00000000..f45b9294 --- /dev/null +++ b/code/app/Athenia/Events/Statistics/StatisticUpdatedEvent.php @@ -0,0 +1,19 @@ +statistic; + } +} \ No newline at end of file diff --git a/code/app/Athenia/Http/Core/Controllers/BaseControllerAbstract.php b/code/app/Athenia/Http/Core/Controllers/BaseControllerAbstract.php index a5fa6185..4324819c 100644 --- a/code/app/Athenia/Http/Core/Controllers/BaseControllerAbstract.php +++ b/code/app/Athenia/Http/Core/Controllers/BaseControllerAbstract.php @@ -47,10 +47,6 @@ * description="Any routes related to foreground asset models." * ) * @SWG\Tag( - * name="AudioClips", - * description="Any routes related to audio clip asset models." - * ) - * @SWG\Tag( * name="Users", * description="Any information that is related to the user object of the app" * ) diff --git a/code/app/Athenia/Http/Core/Controllers/StatisticControllerAbstract.php b/code/app/Athenia/Http/Core/Controllers/StatisticControllerAbstract.php new file mode 100644 index 00000000..6deefb03 --- /dev/null +++ b/code/app/Athenia/Http/Core/Controllers/StatisticControllerAbstract.php @@ -0,0 +1,93 @@ +repository = $repository; + } + + /** + * Display a listing of the resource + * + * @param Requests\Statistics\IndexRequest $request + * @return JsonResponse + */ + public function index(Requests\Statistics\IndexRequest $request) + { + return $this->repository->findAll($this->filter($request), $this->search($request), $this->order($request), $this->expand($request), $this->limit($request), [], (int)$request->input('page', 1)); + } + + /** + * Creates a Statistic model + * + * @param Requests\Statistics\StoreRequest $request + * @return JsonResponse + */ + public function store(Requests\Statistics\StoreRequest $request) + { + $model = $this->repository->create($request->json()->all()); + return response($model, 201); + } + + /** + * View a single Statistic model + * + * @param Requests\Statistics\ViewRequest $request + * @param Statistic $statistic + * @return JsonResponse + */ + public function show(Requests\Statistics\ViewRequest $request, Statistic $statistic) + { + return $statistic->load($this->expand($request)); + } + + /** + * Updates a Statistic model + * + * @param Requests\Statistics\UpdateRequest $request + * @param Statistic $statistic + * @return JsonResponse + */ + public function update(Requests\Statistics\UpdateRequest $request, Statistic $statistic) + { + return $this->repository->update($statistic, $request->json()->all()); + } + + /** + * Deletes a Statistic model + * + * @param Requests\Statistics\DeleteRequest $request + * @param Statistic $statistic + * @return JsonResponse + */ + public function destroy(Requests\Statistics\DeleteRequest $request, Statistic $statistic) + { + $this->repository->delete($statistic); + return response(null, 204); + } +} \ No newline at end of file diff --git a/code/app/Athenia/Http/Core/Requests/Statistics/DeleteRequestAbstract.php b/code/app/Athenia/Http/Core/Requests/Statistics/DeleteRequestAbstract.php new file mode 100644 index 00000000..f2086f06 --- /dev/null +++ b/code/app/Athenia/Http/Core/Requests/Statistics/DeleteRequestAbstract.php @@ -0,0 +1,40 @@ +getValidationRules(Statistic::VALIDATION_RULES_CREATE); + } +} \ No newline at end of file diff --git a/code/app/Athenia/Http/Core/Requests/Statistics/UpdateRequestAbstract.php b/code/app/Athenia/Http/Core/Requests/Statistics/UpdateRequestAbstract.php new file mode 100644 index 00000000..61c0a97b --- /dev/null +++ b/code/app/Athenia/Http/Core/Requests/Statistics/UpdateRequestAbstract.php @@ -0,0 +1,50 @@ +getValidationRules(Statistic::VALIDATION_RULES_UPDATE); + } +} \ No newline at end of file diff --git a/code/app/Athenia/Http/Core/Requests/Statistics/ViewRequestAbstract.php b/code/app/Athenia/Http/Core/Requests/Statistics/ViewRequestAbstract.php new file mode 100644 index 00000000..8e7efbf5 --- /dev/null +++ b/code/app/Athenia/Http/Core/Requests/Statistics/ViewRequestAbstract.php @@ -0,0 +1,49 @@ +target = $target; + } + + /** + * Execute the job. + * + * @param TargetStatisticProcessingServiceContract $processingService + * @return void + */ + public function handle(TargetStatisticProcessingServiceContract $processingService): void + { + foreach ($this->target->targetStatistics as $targetStatistic) { + $processingService->processSingleTargetStatistic($targetStatistic); + } + } +} \ No newline at end of file diff --git a/code/app/Athenia/Jobs/Statistics/RecountStatisticJob.php b/code/app/Athenia/Jobs/Statistics/RecountStatisticJob.php new file mode 100644 index 00000000..d5c5bd48 --- /dev/null +++ b/code/app/Athenia/Jobs/Statistics/RecountStatisticJob.php @@ -0,0 +1,43 @@ +statistic->targetStatistics as $targetStatistic) { + $processingService->processSingleTargetStatistic($targetStatistic); + } + } +} \ No newline at end of file diff --git a/code/app/Athenia/Listeners/Statistics/StatisticCreatedListener.php b/code/app/Athenia/Listeners/Statistics/StatisticCreatedListener.php new file mode 100644 index 00000000..29c6b444 --- /dev/null +++ b/code/app/Athenia/Listeners/Statistics/StatisticCreatedListener.php @@ -0,0 +1,30 @@ +getStatistic(); + + // Create target statistics for the new statistic + $this->synchronizationService->createTargetStatisticsForStatistic($statistic); + + // Trigger a recount job to process the new target statistics + $this->dispatcher->dispatch(new RecountStatisticJob($statistic)); + } +} \ No newline at end of file diff --git a/code/app/Athenia/Listeners/Statistics/StatisticDeletedListener.php b/code/app/Athenia/Listeners/Statistics/StatisticDeletedListener.php new file mode 100644 index 00000000..3045a526 --- /dev/null +++ b/code/app/Athenia/Listeners/Statistics/StatisticDeletedListener.php @@ -0,0 +1,27 @@ +getStatistic(); + + // Delete all target statistics related to this statistic + foreach ($statistic->targetStatistics as $targetStatistic) { + $this->targetStatisticRepository->delete($targetStatistic); + } + } +} \ No newline at end of file diff --git a/code/app/Athenia/Listeners/Statistics/StatisticUpdatedListener.php b/code/app/Athenia/Listeners/Statistics/StatisticUpdatedListener.php new file mode 100644 index 00000000..ce7018e4 --- /dev/null +++ b/code/app/Athenia/Listeners/Statistics/StatisticUpdatedListener.php @@ -0,0 +1,24 @@ +getStatistic(); + $statistic->unsetRelations(); + $this->dispatcher->dispatch(new RecountStatisticJob($statistic)); + } +} \ No newline at end of file diff --git a/code/app/Athenia/Mail/MessageMailer.php b/code/app/Athenia/Mail/MessageMailer.php index 9e70006c..861f2cd3 100644 --- a/code/app/Athenia/Mail/MessageMailer.php +++ b/code/app/Athenia/Mail/MessageMailer.php @@ -38,10 +38,14 @@ public function build() { $email = $this->message->email ?? $this->receiver->getEmailAddress(); $name = $this->receiver->getEmailToName(); + $data = $this->message->data; + if (isset ($data['message'])) { + $data['message_content'] = $data['message']; + } $message = $this->subject($this->message->subject) ->to($email, $name) ->from(config('mail.from.address'), config('mail.from.name')) - ->view('mailers.' . $this->message->template, $this->message->data); + ->view('mailers.' . $this->message->template, $data); if ($this->message->reply_to_email) { $message->replyTo($this->message->reply_to_email, $this->message->reply_to_name); diff --git a/code/app/Athenia/Models/Traits/HasStatisticTargets.php b/code/app/Athenia/Models/Traits/HasStatisticTargets.php new file mode 100644 index 00000000..cfaeccf2 --- /dev/null +++ b/code/app/Athenia/Models/Traits/HasStatisticTargets.php @@ -0,0 +1,24 @@ +morphMany(TargetStatistic::class, 'target'); + } +} \ No newline at end of file diff --git a/code/app/Athenia/Observers/AggregatedModelObserver.php b/code/app/Athenia/Observers/AggregatedModelObserver.php new file mode 100644 index 00000000..17f80013 --- /dev/null +++ b/code/app/Athenia/Observers/AggregatedModelObserver.php @@ -0,0 +1,66 @@ +dispatchStatisticProcessing($model); + } + + /** + * Handle the Model "updated" event. + */ + public function updated(CanBeAggregatedContract $model): void + { + $this->dispatchStatisticProcessing($model); + } + + /** + * Handle the Model "deleted" event. + */ + public function deleted(CanBeAggregatedContract $model): void + { + $this->dispatchStatisticProcessing($model); + } + + /** + * Handle the Model "restored" event. + */ + public function restored(Model $model): void + { + $this->dispatchStatisticProcessing($model); + } + + /** + * Dispatches statistic processing for models that can be aggregated + */ + private function dispatchStatisticProcessing(CanBeAggregatedContract $model): void + { + foreach ($model->getStatisticTargetRelationPath() as $relationPath) { + $targetModels = $this->relationTraversalService->traverseRelations($model, $relationPath); + + foreach ($targetModels as $targetModel) { + if ($targetModel instanceof CanBeStatisticTargetContract) { + $this->dispatcher->dispatch(new ProcessTargetStatisticsJob($targetModel)); + } + } + } + } +} \ No newline at end of file diff --git a/code/app/Athenia/Observer/IndexableModelObserver.php b/code/app/Athenia/Observers/IndexableModelObserver.php similarity index 91% rename from code/app/Athenia/Observer/IndexableModelObserver.php rename to code/app/Athenia/Observers/IndexableModelObserver.php index de34c4b4..40b11014 100644 --- a/code/app/Athenia/Observer/IndexableModelObserver.php +++ b/code/app/Athenia/Observers/IndexableModelObserver.php @@ -1,14 +1,14 @@ getContentString()) { + $content = $model->getContentString(); + if ($content) { $data = [ - 'content' => $model->getContentString(), + 'content' => $content, 'resource_id' => $model->id, 'resource_type' => $model->morphRelationName(), ]; diff --git a/code/app/Athenia/Observer/Payment/PaymentMethodObserver.php b/code/app/Athenia/Observers/Payment/PaymentMethodObserver.php similarity index 96% rename from code/app/Athenia/Observer/Payment/PaymentMethodObserver.php rename to code/app/Athenia/Observers/Payment/PaymentMethodObserver.php index 838687cc..0ff4011a 100644 --- a/code/app/Athenia/Observer/Payment/PaymentMethodObserver.php +++ b/code/app/Athenia/Observers/Payment/PaymentMethodObserver.php @@ -1,7 +1,7 @@ hasRole([Role::SUPER_ADMIN]) ?: null; } -} \ No newline at end of file +} \ No newline at end of file diff --git a/code/app/Athenia/Providers/BaseEventServiceProvider.php b/code/app/Athenia/Providers/BaseEventServiceProvider.php index 154b86f3..845214f2 100644 --- a/code/app/Athenia/Providers/BaseEventServiceProvider.php +++ b/code/app/Athenia/Providers/BaseEventServiceProvider.php @@ -14,6 +14,9 @@ use App\Athenia\Events\User\SignUpEvent; use App\Athenia\Events\User\UserMergeEvent; use App\Athenia\Events\Vote\VoteCreatedEvent; +use App\Athenia\Events\Statistics\StatisticUpdatedEvent; +use App\Athenia\Events\Statistics\StatisticCreatedEvent; +use App\Athenia\Events\Statistics\StatisticDeletedEvent; use App\Athenia\Listeners\Article\ArticleVersionCreatedListener; use App\Athenia\Listeners\Messaging\MessageCreatedListener; use App\Athenia\Listeners\Messaging\MessageSentListener; @@ -25,12 +28,15 @@ use App\Athenia\Listeners\User\UserMerge\UserMessagesMergeListener; use App\Athenia\Listeners\User\UserMerge\UserPropertiesMergeListener; use App\Athenia\Listeners\User\UserMerge\UserSubscriptionsMergeListener; -use App\Athenia\Observer\IndexableModelObserver; -use App\Athenia\Observer\Payment\PaymentMethodObserver; +use App\Athenia\Observers\IndexableModelObserver; +use App\Athenia\Observers\Payment\PaymentMethodObserver; use App\Listeners\Organization\OrganizationManagerCreatedListener; use App\Listeners\User\Contact\ContactCreatedListener; use App\Listeners\User\SignUpListener; use App\Listeners\Vote\VoteCreatedListener; +use App\Athenia\Listeners\Statistics\StatisticUpdatedListener; +use App\Athenia\Listeners\Statistics\StatisticCreatedListener; +use App\Athenia\Listeners\Statistics\StatisticDeletedListener; use App\Models\Payment\PaymentMethod; use App\Models\User\User; use App\Models\Wiki\Article; @@ -88,6 +94,15 @@ public function listens(): array VoteCreatedEvent::class => [ VoteCreatedListener::class, ], + StatisticUpdatedEvent::class => [ + StatisticUpdatedListener::class, + ], + StatisticCreatedEvent::class => [ + StatisticCreatedListener::class, + ], + StatisticDeletedEvent::class => [ + StatisticDeletedListener::class, + ], ], $this->getAppListenerMapping()); } diff --git a/code/app/Athenia/Providers/BaseRepositoryProvider.php b/code/app/Athenia/Providers/BaseRepositoryProvider.php index bd5e7124..32437aec 100644 --- a/code/app/Athenia/Providers/BaseRepositoryProvider.php +++ b/code/app/Athenia/Providers/BaseRepositoryProvider.php @@ -33,8 +33,14 @@ use App\Athenia\Contracts\Repositories\Wiki\ArticleModificationRepositoryContract; use App\Athenia\Contracts\Repositories\Wiki\ArticleRepositoryContract; use App\Athenia\Contracts\Repositories\Wiki\ArticleVersionRepositoryContract; +use App\Athenia\Contracts\Repositories\Statistics\TargetStatisticRepositoryContract; +use App\Athenia\Contracts\Repositories\Statistics\StatisticRepositoryContract; use App\Athenia\Contracts\Services\Asset\AssetConfigurationServiceContract; use App\Athenia\Contracts\Services\TokenGenerationServiceContract; +use App\Athenia\Contracts\Services\Wiki\ArticleVersionCalculationServiceContract; +use App\Athenia\Contracts\Services\Relations\RelationTraversalServiceContract; +use App\Athenia\Contracts\Services\Statistics\StatisticSynchronizationServiceContract; +use App\Athenia\Contracts\Services\Statistics\TargetStatisticProcessingServiceContract; use App\Athenia\Repositories\AssetRepository; use App\Athenia\Repositories\CategoryRepository; use App\Athenia\Repositories\Collection\CollectionItemRepository; @@ -65,7 +71,9 @@ use App\Athenia\Repositories\Wiki\ArticleModificationRepository; use App\Athenia\Repositories\Wiki\ArticleRepository; use App\Athenia\Repositories\Wiki\ArticleVersionRepository; -use App\Athenia\Services\Asset\AssetConfigurationService; +use App\Athenia\Repositories\Statistics\StatisticRepository; +use App\Athenia\Repositories\Statistics\StatisticFilterRepository; +use App\Athenia\Repositories\Statistics\TargetStatisticRepository; use App\Models\Asset; use App\Models\Category; use App\Models\Collection\Collection; @@ -96,11 +104,14 @@ use App\Models\Wiki\ArticleIteration; use App\Models\Wiki\ArticleModification; use App\Models\Wiki\ArticleVersion; +use App\Models\Statistics\TargetStatistic; +use App\Models\Statistics\Statistic; use Illuminate\Contracts\Config\Repository; use Illuminate\Contracts\Events\Dispatcher; use Illuminate\Contracts\Hashing\Hasher; use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Support\ServiceProvider; +use Illuminate\Filesystem\FilesystemManager; /** * Class AtheniaRepositoryProvider @@ -140,10 +151,12 @@ public final function provides(): array ProfileImageRepositoryContract::class, ResourceRepositoryContract::class, RoleRepositoryContract::class, + StatisticRepositoryContract::class, SubscriptionRepositoryContract::class, + TargetStatisticRepositoryContract::class, ThreadRepositoryContract::class, - VoteRepositoryContract::class, UserRepositoryContract::class, + VoteRepositoryContract::class, ], $this->appProviders()); } @@ -166,6 +179,7 @@ public final function register(): void 'organization' => Organization::class, 'subscription' => Subscription::class, 'user' => User::class, + 'collection' => Collection::class, ], $this->appMorphMaps())); $this->app->bind(ArticleRepositoryContract::class, function() { @@ -198,152 +212,171 @@ public final function register(): void new Asset(), $this->app->make('log'), $this->app->make('filesystem'), - $this->app->make(AssetConfigurationServiceContract::class), + $this->app->make(AssetConfigurationServiceContract::class) ); }); - $this->app->bind(BallotRepositoryContract::class, function () { + $this->app->bind(BallotRepositoryContract::class, function() { return new BallotRepository( new Ballot(), $this->app->make('log'), - $this->app->make(BallotItemRepositoryContract::class), + $this->app->make(BallotItemRepositoryContract::class) ); }); - $this->app->bind(BallotCompletionRepositoryContract::class, function () { + $this->app->bind(BallotCompletionRepositoryContract::class, function() { return new BallotCompletionRepository( new BallotCompletion(), $this->app->make('log'), - $this->app->make(VoteRepositoryContract::class), - ); - }); - $this->app->bind(BallotItemOptionRepositoryContract::class, function () { - return new BallotItemOptionRepository( - new BallotItemOption(), - $this->app->make('log'), + $this->app->make(VoteRepositoryContract::class) ); }); - $this->app->bind(BallotItemRepositoryContract::class, function () { + $this->app->bind(BallotItemRepositoryContract::class, function() { return new BallotItemRepository( new BallotItem(), $this->app->make('log'), - $this->app->make(BallotItemOptionRepositoryContract::class), + $this->app->make(BallotItemOptionRepositoryContract::class) ); }); - $this->app->bind(CategoryRepositoryContract::class, function () { + $this->app->bind(BallotItemOptionRepositoryContract::class, function() { + return new BallotItemOptionRepository( + new BallotItemOption(), + $this->app->make('log') + ); + }); + $this->app->bind(CategoryRepositoryContract::class, function() { return new CategoryRepository( new Category(), - $this->app->make('log'), + $this->app->make('log') ); }); - $this->app->bind(CollectionRepositoryContract::class, function () { + $this->app->bind(CollectionRepositoryContract::class, function() { return new CollectionRepository( new Collection(), $this->app->make('log'), - $this->app->make(CollectionItemRepositoryContract::class), + $this->app->make(CollectionItemRepositoryContract::class) ); }); - $this->app->bind(CollectionItemRepositoryContract::class, function () { + $this->app->bind(CollectionItemRepositoryContract::class, function() { return new CollectionItemRepository( new CollectionItem(), - $this->app->make('log'), + $this->app->make('log') ); }); - $this->app->bind(ContactRepositoryContract::class, function () { + $this->app->bind(ContactRepositoryContract::class, function() { return new ContactRepository( new Contact(), - $this->app->make('log'), + $this->app->make('log') ); }); $this->app->bind(FeatureRepositoryContract::class, function() { - return new FeatureRepository(new Feature(), $this->app->make('log')); + return new FeatureRepository( + new Feature(), + $this->app->make('log') + ); }); - $this->app->bind(LineItemRepositoryContract::class, function () { + $this->app->bind(LineItemRepositoryContract::class, function() { return new LineItemRepository( new LineItem(), - $this->app->make('log'), + $this->app->make('log') ); }); $this->app->bind(MembershipPlanRepositoryContract::class, function() { return new MembershipPlanRepository( new MembershipPlan(), $this->app->make('log'), - $this->app->make(MembershipPlanRateRepositoryContract::class), + $this->app->make(MembershipPlanRateRepositoryContract::class) ); }); $this->app->bind(MembershipPlanRateRepositoryContract::class, function() { return new MembershipPlanRateRepository( new MembershipPlanRate(), - $this->app->make('log'), + $this->app->make('log') ); }); $this->app->bind(MessageRepositoryContract::class, function() { return new MessageRepository( new Message(), $this->app->make('log'), - $this->app->make(UserRepositoryContract::class), + $this->app->make(UserRepositoryContract::class) ); }); - $this->app->bind(OrganizationRepositoryContract::class, function () { - return new OrganizationRepository(new Organization(), $this->app->make('log')); + $this->app->bind(OrganizationRepositoryContract::class, function() { + return new OrganizationRepository( + new Organization(), + $this->app->make('log') + ); }); - $this->app->bind(OrganizationManagerRepositoryContract::class, function () { - return new OrganizationManagerRepository(new OrganizationManager(), $this->app->make('log')); + $this->app->bind(OrganizationManagerRepositoryContract::class, function() { + return new OrganizationManagerRepository( + new OrganizationManager(), + $this->app->make('log') + ); }); $this->app->bind(PasswordTokenRepositoryContract::class, function() { return new PasswordTokenRepository( new PasswordToken(), $this->app->make('log'), $this->app->make(Dispatcher::class), - $this->app->make(TokenGenerationServiceContract::class), + $this->app->make(TokenGenerationServiceContract::class) ); }); $this->app->bind(PaymentRepositoryContract::class, function() { return new PaymentRepository( new Payment(), $this->app->make('log'), - $this->app->make(LineItemRepositoryContract::class), + $this->app->make(LineItemRepositoryContract::class) ); }); $this->app->bind(PaymentMethodRepositoryContract::class, function() { return new PaymentMethodRepository( new PaymentMethod(), - $this->app->make('log'), + $this->app->make('log') ); }); $this->app->bind(ProfileImageRepositoryContract::class, function() { return new ProfileImageRepository( new ProfileImage(), $this->app->make('log'), - $this->app->make('filesystem'), - new AssetConfigurationService( - - $this->app->make('config')->get('app.asset_url'), - "profile_images", - ), + $this->app->make(FilesystemManager::class), + $this->app->make(AssetConfigurationServiceContract::class) ); }); $this->app->bind(ResourceRepositoryContract::class, function() { return new ResourceRepository( new Resource(), - $this->app->make('log'), + $this->app->make('log') ); }); $this->app->bind(RoleRepositoryContract::class, function() { return new RoleRepository( new Role(), + $this->app->make('log') + ); + }); + $this->app->bind(StatisticRepositoryContract::class, function() { + return new StatisticRepository( + new Statistic(), $this->app->make('log'), + $this->app->make(StatisticFilterRepository::class), + $this->app->make(Dispatcher::class) ); }); $this->app->bind(SubscriptionRepositoryContract::class, function() { return new SubscriptionRepository( new Subscription(), $this->app->make('log'), - $this->app->make(MembershipPlanRateRepositoryContract::class), + $this->app->make(MembershipPlanRateRepositoryContract::class) + ); + }); + $this->app->bind(TargetStatisticRepositoryContract::class, function() { + return new TargetStatisticRepository( + new TargetStatistic(), + $this->app->make('log') ); }); $this->app->bind(ThreadRepositoryContract::class, function() { return new ThreadRepository( new Thread(), - $this->app->make('log'), + $this->app->make('log') ); }); $this->app->bind(UserRepositoryContract::class, function() { @@ -351,13 +384,13 @@ public final function register(): void new User(), $this->app->make('log'), $this->app->make(Hasher::class), - $this->app->make(Repository::class), + $this->app->make(Repository::class) ); }); - $this->app->bind(VoteRepositoryContract::class, function () { + $this->app->bind(VoteRepositoryContract::class, function() { return new VoteRepository( new Vote(), - $this->app->make('log'), + $this->app->make('log') ); }); $this->registerApp(); diff --git a/code/app/Athenia/Providers/BaseRouteServiceProvider.php b/code/app/Athenia/Providers/BaseRouteServiceProvider.php index a62e4875..39287055 100644 --- a/code/app/Athenia/Providers/BaseRouteServiceProvider.php +++ b/code/app/Athenia/Providers/BaseRouteServiceProvider.php @@ -11,6 +11,7 @@ use App\Models\Organization\OrganizationManager; use App\Models\Payment\PaymentMethod; use App\Models\Role; +use App\Models\Statistics\Statistic; use App\Models\Subscription\MembershipPlan; use App\Models\Subscription\Subscription; use App\Models\User\User; @@ -55,6 +56,7 @@ public function getModelPlaceholders(): array 'organization_manager' => OrganizationManager::class, 'payment_method' => PaymentMethod::class, 'role' => Role::class, + 'statistic' => Statistic::class, 'subscription' => Subscription::class, 'user' => User::class, ], $this->getAppModelPlaceholders()); diff --git a/code/app/Athenia/Providers/BaseServiceProvider.php b/code/app/Athenia/Providers/BaseServiceProvider.php index a02f8ddb..17545d57 100644 --- a/code/app/Athenia/Providers/BaseServiceProvider.php +++ b/code/app/Athenia/Providers/BaseServiceProvider.php @@ -10,6 +10,8 @@ use App\Athenia\Contracts\Repositories\Payment\PaymentRepositoryContract; use App\Athenia\Contracts\Repositories\Subscription\SubscriptionRepositoryContract; use App\Athenia\Contracts\Repositories\User\UserRepositoryContract; +use App\Athenia\Contracts\Repositories\Statistics\StatisticRepositoryContract; +use App\Athenia\Contracts\Repositories\Statistics\TargetStatisticRepositoryContract; use App\Athenia\Contracts\Services\ArchiveHelperServiceContract; use App\Athenia\Contracts\Services\Asset\AssetConfigurationServiceContract; use App\Athenia\Contracts\Services\Asset\AssetImportServiceContract; @@ -28,6 +30,9 @@ use App\Athenia\Contracts\Services\StripePaymentServiceContract; use App\Athenia\Contracts\Services\TokenGenerationServiceContract; use App\Athenia\Contracts\Services\Wiki\ArticleVersionCalculationServiceContract; +use App\Athenia\Contracts\Services\Relations\RelationTraversalServiceContract; +use App\Athenia\Contracts\Services\Statistics\StatisticSynchronizationServiceContract; +use App\Athenia\Contracts\Services\Statistics\TargetStatisticProcessingServiceContract; use App\Athenia\Services\ArchiveHelperService; use App\Athenia\Services\Asset\AssetConfigurationService; use App\Athenia\Services\Asset\AssetImportService; @@ -46,6 +51,9 @@ use App\Athenia\Services\StripePaymentService; use App\Athenia\Services\TokenGenerationService; use App\Athenia\Services\Wiki\ArticleVersionCalculationService; +use App\Athenia\Services\Relations\RelationTraversalService; +use App\Athenia\Services\Statistics\StatisticSynchronizationService; +use App\Athenia\Services\Statistics\TargetStatisticProcessingService; use App\Models\Messaging\Message; use App\Services\Indexing\ResourceRepositoryService; use Barryvdh\LaravelIdeHelper\IdeHelperServiceProvider; @@ -71,14 +79,17 @@ public function provides(): array ItemInEntityCollectionServiceContract::class, MessageSendingSelectionServiceContract::class, ProratingCalculationServiceContract::class, + RelationTraversalServiceContract::class, ResourceRepositoryServiceContract::class, SendEmailServiceContract::class, SendPushNotificationServiceContract::class, SendSlackNotificationServiceContract::class, SendSMSServiceContract::class, + StatisticSynchronizationServiceContract::class, StringHelperServiceContract::class, StripeCustomerServiceContract::class, StripePaymentServiceContract::class, + TargetStatisticProcessingServiceContract::class, TokenGenerationServiceContract::class, ], $this->appProviders()); } @@ -200,8 +211,24 @@ public function register(): void $this->app->make(Dispatcher::class), $stripe->charges(), $stripe->refunds(), + $this->app->make('log') ); }); + $this->app->bind(RelationTraversalServiceContract::class, fn () => + new RelationTraversalService() + ); + $this->app->bind(StatisticSynchronizationServiceContract::class, fn () => + new StatisticSynchronizationService( + $this->app->make(StatisticRepositoryContract::class), + $this->app->make(TargetStatisticRepositoryContract::class) + ) + ); + $this->app->bind(TargetStatisticProcessingServiceContract::class, fn () => + new TargetStatisticProcessingService( + $this->app->make(RelationTraversalServiceContract::class), + $this->app->make(TargetStatisticRepositoryContract::class) + ) + ); $this->app->bind(TokenGenerationServiceContract::class, fn () => new TokenGenerationService() ); diff --git a/code/app/Athenia/Repositories/BaseRepositoryAbstract.php b/code/app/Athenia/Repositories/BaseRepositoryAbstract.php index aa1687f5..ea88919a 100644 --- a/code/app/Athenia/Repositories/BaseRepositoryAbstract.php +++ b/code/app/Athenia/Repositories/BaseRepositoryAbstract.php @@ -295,8 +295,9 @@ protected function syncChildModels(BaseRepositoryContract $childRepository, Base string $morphRelationship = null) { if ($existingChildren) { - $newChildrenIds = collect($childrenData)->pluck('id'); + $newChildrenIds = collect($childrenData)->pluck('id')->filter(); + // Delete children that are not in the new data foreach ($existingChildren as $child) { if (!$newChildrenIds->contains($child->id)) { $childRepository->delete($child); diff --git a/code/app/Athenia/Repositories/Statistics/StatisticFilterRepository.php b/code/app/Athenia/Repositories/Statistics/StatisticFilterRepository.php new file mode 100644 index 00000000..ecc8438d --- /dev/null +++ b/code/app/Athenia/Repositories/Statistics/StatisticFilterRepository.php @@ -0,0 +1,18 @@ +getAndUnset($data, 'statistic_filters'); + + $model = parent::update($model, $data, $forcedValues); + + if ($statisticFilters !== null) { + $this->syncChildModels( + $this->statisticFilterRepository, + $model, + $statisticFilters, + $model->statisticFilters + ); + } + + $this->dispatcher->dispatch(new StatisticUpdatedEvent($model)); + + return $model; + } + + /** + * @inheritDoc + */ + public function create(array $data = [], ?BaseModelAbstract $relatedModel = null, array $forcedValues = []) + { + $statisticFilters = $this->getAndUnset($data, 'statistic_filters') ?? []; + + $model = parent::create($data, $relatedModel, $forcedValues); + + if ($statisticFilters) { + $this->syncChildModels( + $this->statisticFilterRepository, + $model, + $statisticFilters + ); + } + + $this->dispatcher->dispatch(new StatisticCreatedEvent($model)); + + return $model; + } + + public function delete(BaseModelAbstract $model): void + { + parent::delete($model); + $this->dispatcher->dispatch(new StatisticDeletedEvent($model)); + } +} \ No newline at end of file diff --git a/code/app/Athenia/Repositories/Statistics/TargetStatisticRepository.php b/code/app/Athenia/Repositories/Statistics/TargetStatisticRepository.php new file mode 100644 index 00000000..83ab38a8 --- /dev/null +++ b/code/app/Athenia/Repositories/Statistics/TargetStatisticRepository.php @@ -0,0 +1,75 @@ + $data The data to create the statistic with + * @return TargetStatistic The newly created target statistic + */ + public function createForTarget(CanBeStatisticTargetContract $target, array $data): TargetStatistic + { + $data['target_id'] = $target->id; + $data['target_type'] = $target->morphRelationName(); + + return $this->create($data); + } + + /** + * Find all statistics for a specific target + * + * @param CanBeStatisticTargetContract $target The target model to find statistics for + * @return Collection Collection of target statistics + */ + public function findAllForTarget(CanBeStatisticTargetContract $target): Collection + { + return $this->model + ->where('target_type', $target->morphRelationName()) + ->where('target_id', $target->id) + ->get(); + } + + /** + * Find a specific statistic for a target + * + * @param CanBeStatisticTargetContract $target The target model to find the statistic for + * @param int $statisticId The ID of the statistic to find + * @return TargetStatistic|null The found target statistic or null if not found + */ + public function findForTarget(CanBeStatisticTargetContract $target, int $statisticId): ?TargetStatistic + { + return $this->model + ->where('target_type', $target->morphRelationName()) + ->where('target_id', $target->id) + ->where('statistic_id', $statisticId) + ->first(); + } +} \ No newline at end of file diff --git a/code/app/Athenia/Services/Relations/RelationTraversalService.php b/code/app/Athenia/Services/Relations/RelationTraversalService.php new file mode 100644 index 00000000..565c8a33 --- /dev/null +++ b/code/app/Athenia/Services/Relations/RelationTraversalService.php @@ -0,0 +1,57 @@ +relationLoaded($relation)) { + $model->load($relation); + } + + $related = $model->{$relation}; + + // Handle both single models and collections + if ($related instanceof Collection) { + $nextModels = $nextModels->merge($related); + } elseif ($related instanceof BaseModelAbstract) { + $nextModels->push($related); + } + } + + $currentModels = $nextModels; + } + + return $currentModels; + } +} \ No newline at end of file diff --git a/code/app/Athenia/Services/Statistics/StatisticSynchronizationService.php b/code/app/Athenia/Services/Statistics/StatisticSynchronizationService.php new file mode 100644 index 00000000..b792a28a --- /dev/null +++ b/code/app/Athenia/Services/Statistics/StatisticSynchronizationService.php @@ -0,0 +1,109 @@ +targetStatistics ?? new Collection(); + $statistics = $this->statisticRepository->findAll(['model' => $model->morphRelationName()]); + $newTargetStatistics = new Collection(); + + foreach ($statistics as $statistic) { + if (!$existingTargetStatistics->contains('statistic_id', $statistic->id)) { + $newTargetStatistic = $this->targetStatisticRepository->create([ + 'statistic_id' => $statistic->id, + 'target_id' => $model->id, + 'target_type' => $model->morphRelationName(), + ]); + + $newTargetStatistics->push($newTargetStatistic); + } + } + + // Create a new Eloquent Collection with all items + $allItems = array_merge($existingTargetStatistics->all(), $newTargetStatistics->all()); + return new Collection($allItems); + } + + /** + * Creates target statistics for all models that should have them for the given statistic + * + * @param Statistic $statistic + * @return Collection + */ + public function createTargetStatisticsForStatistic(Statistic $statistic): Collection + { + $targetStatistics = new Collection(); + $models = $this->getModelsForStatistic($statistic); + + foreach ($models as $model) { + // Check if a target statistic already exists for this model and statistic + $existingTargetStatistic = $this->targetStatisticRepository->findForTarget($model, $statistic->id); + + if (!$existingTargetStatistic) { + $targetStatistic = $this->targetStatisticRepository->create([ + 'statistic_id' => $statistic->id, + 'target_id' => $model->id, + 'target_type' => $model->morphRelationName(), + ]); + + $targetStatistics->push($targetStatistic); + } else { + $targetStatistics->push($existingTargetStatistic); + } + } + + return $targetStatistics; + } + + /** + * Get all models that should have target statistics for the given statistic. + * + * @param Statistic $statistic + * @return CanBeStatisticTargetContract[] + */ + private function getModelsForStatistic(Statistic $statistic): array + { + // Get the model class from Laravel's morph map + $modelClass = Relation::getMorphedModel($statistic->model); + + if (!$modelClass) { + throw new \RuntimeException("No morph map found for model type: {$statistic->model}"); + } + + // Build and execute the query using the model's query builder + return $modelClass::query()->get()->all(); + } +} \ No newline at end of file diff --git a/code/app/Athenia/Services/Statistics/TargetStatisticProcessingService.php b/code/app/Athenia/Services/Statistics/TargetStatisticProcessingService.php new file mode 100644 index 00000000..e48e4e8c --- /dev/null +++ b/code/app/Athenia/Services/Statistics/TargetStatisticProcessingService.php @@ -0,0 +1,144 @@ +relationTraversalService->traverseRelations( + $targetStatistic->target, + $targetStatistic->statistic->relation + ); + + // Get all filters for this statistic + $filters = $targetStatistic->statistic->filters; + + // Apply filters to the models + $filteredModels = $this->applyFilters($models, $filters); + + // Check if any filter requires unique values + $uniqueFilter = $filters->first(function (StatisticFilter $filter) { + return $filter->operator === 'unique'; + }); + + // Process results based on whether we need unique values or a total count + $result = $uniqueFilter + ? $this->processUniqueResults($filteredModels, $uniqueFilter) + : ['total' => $filteredModels->count()]; + + // Update the target statistic through the repository + $this->targetStatisticRepository->update($targetStatistic, ['result' => $result]); + } + + /** + * Applies all filters to the collection of models + * + * @param Collection $models + * @param Collection $filters + * @return Collection + */ + private function applyFilters(Collection $models, Collection $filters): Collection + { + return $models->filter(function ($model) use ($filters) { + foreach ($filters as $filter) { + if ($filter->operator === 'unique') { + continue; + } + + $fieldValue = data_get($model, $filter->field); + $filterValue = $filter->value; + + if (!$this->evaluateFilter($fieldValue, $filter->operator, $filterValue)) { + return false; + } + } + return true; + }); + } + + /** + * Evaluates a single filter condition + * + * @param mixed $fieldValue + * @param string $operator + * @param mixed $filterValue + * @return bool + */ + private function evaluateFilter($fieldValue, string $operator, $filterValue): bool + { + switch ($operator) { + case '=': + return $fieldValue == $filterValue; + case '!=': + return $fieldValue != $filterValue; + case '>': + return $fieldValue > $filterValue; + case '>=': + return $fieldValue >= $filterValue; + case '<': + return $fieldValue < $filterValue; + case '<=': + return $fieldValue <= $filterValue; + case 'in': + return in_array($fieldValue, explode(',', $filterValue)); + case 'not in': + return !in_array($fieldValue, explode(',', $filterValue)); + case 'like': + return str_contains(strtolower($fieldValue), strtolower($filterValue)); + case 'not like': + return !str_contains(strtolower($fieldValue), strtolower($filterValue)); + default: + return false; + } + } + + /** + * Processes results for unique value grouping + * + * @param Collection $models + * @param StatisticFilter $uniqueFilter + * @return array + */ + private function processUniqueResults(Collection $models, StatisticFilter $uniqueFilter): array + { + $uniqueValues = $models->pluck($uniqueFilter->field)->unique(); + $result = []; + + foreach ($uniqueValues as $value) { + $result[$value] = $models->where($uniqueFilter->field, $value)->count(); + } + + return $result; + } +} \ No newline at end of file diff --git a/code/app/Http/Core/Requests/Statistics/DeleteRequest.php b/code/app/Http/Core/Requests/Statistics/DeleteRequest.php new file mode 100644 index 00000000..2e755dee --- /dev/null +++ b/code/app/Http/Core/Requests/Statistics/DeleteRequest.php @@ -0,0 +1,14 @@ + $collectionItems * @property-read \Illuminate\Database\Eloquent\Model|\Eloquent $owner + * @property-read \Illuminate\Database\Eloquent\Collection $targetStatistics * @method static \Database\Factories\Collection\CollectionFactory factory(...$parameters) * @method static \AdminUI\Laravel\EloquentJoin\EloquentJoinBuilder|Collection getAggregateMethod() * @method static \AdminUI\Laravel\EloquentJoin\EloquentJoinBuilder|Collection isAppendRelationsCount() @@ -59,9 +64,9 @@ * @method static \Illuminate\Database\Eloquent\Builder|Collection withoutTrashed() * @mixin \Eloquent */ -class Collection extends BaseModelAbstract implements HasValidationRulesContract +class Collection extends BaseModelAbstract implements HasValidationRulesContract, CanBeStatisticTargetContract { - use HasValidationRules; + use HasValidationRules, HasStatisticTargets; /** * All collection items @@ -114,4 +119,14 @@ public function buildModelValidationRules(...$params): array ], ]; } + + /** + * The name of the morph relation + * + * @return string + */ + public function morphRelationName(): string + { + return 'collection'; + } } \ No newline at end of file diff --git a/code/app/Models/Collection/CollectionItem.php b/code/app/Models/Collection/CollectionItem.php index 255fae10..b364ef6c 100644 --- a/code/app/Models/Collection/CollectionItem.php +++ b/code/app/Models/Collection/CollectionItem.php @@ -3,6 +3,7 @@ namespace App\Models\Collection; +use App\Athenia\Contracts\Models\CanBeAggregatedContract; use App\Athenia\Contracts\Models\HasValidationRulesContract; use App\Athenia\Models\BaseModelAbstract; use App\Athenia\Models\Traits\HasValidationRules; @@ -60,7 +61,7 @@ * @method static \Illuminate\Database\Eloquent\Builder|CollectionItem withoutTrashed() * @mixin \Eloquent */ -class CollectionItem extends BaseModelAbstract implements HasValidationRulesContract +class CollectionItem extends BaseModelAbstract implements HasValidationRulesContract, CanBeAggregatedContract { use HasValidationRules; @@ -117,4 +118,16 @@ public function buildModelValidationRules(...$params): array ] ]; } + + /** + * Returns the relation paths to the models that can be target statistics + * For example: ["collection"] would mean this model affects statistics on collections + * through the collection relation + * + * @return string[] + */ + public function getStatisticTargetRelationPath(): array + { + return ['collection']; + } } \ No newline at end of file diff --git a/code/app/Models/Role.php b/code/app/Models/Role.php index fbf7f293..97df3df8 100644 --- a/code/app/Models/Role.php +++ b/code/app/Models/Role.php @@ -37,6 +37,8 @@ class Role extends BaseModelAbstract implements HasPolicyContract const ARTICLE_EDITOR = 4; const ADMINISTRATOR = 10; const MANAGER = 11; + const CONTENT_EDITOR = 100; + const SUPPORT_STAFF = 101; // Add more roles here start with 100 in order to avoid application collision /** @@ -47,6 +49,8 @@ class Role extends BaseModelAbstract implements HasPolicyContract self::SUPER_ADMIN, self::ARTICLE_VIEWER, self::ARTICLE_EDITOR, + self::CONTENT_EDITOR, + self::SUPPORT_STAFF, // Add application specific roles here too ]; diff --git a/code/app/Models/Statistics/Statistic.php b/code/app/Models/Statistics/Statistic.php new file mode 100644 index 00000000..f04c6231 --- /dev/null +++ b/code/app/Models/Statistics/Statistic.php @@ -0,0 +1,110 @@ +hasMany(StatisticFilter::class); + } + + /** + * All instances of the target statistics in the system + * + * @return HasMany + */ + public function targetStatistics(): HasMany + { + return $this->hasMany(TargetStatistic::class); + } + + /** + * @inheritDoc + */ + public function buildModelValidationRules(...$params): array + { + return [ + static::VALIDATION_RULES_BASE => [ + 'name' => [ + 'string', + ], + 'model' => [ + 'string', + ], + 'relation' => [ + 'string', + ], + 'public' => [ + 'boolean', + ], + 'statistic_filters' => [ + 'array', + ], + 'statistic_filters.*' => [ + 'array', + ], + 'statistic_filters.*.field' => [ + 'required', + 'string', + ], + 'statistic_filters.*.operator' => [ + 'required', + 'string', + ], + 'statistic_filters.*.value' => [ + 'nullable', + 'string', + ], + ], + static::VALIDATION_RULES_CREATE => [ + static::VALIDATION_PREPEND_REQUIRED => [ + 'name', + 'model', + 'relation', + ], + ], + static::VALIDATION_RULES_UPDATE => [ + static::VALIDATION_PREPEND_NOT_PRESENT => [ + 'model', + 'relation', + ], + ], + ]; + } +} \ No newline at end of file diff --git a/code/app/Models/Statistics/StatisticFilter.php b/code/app/Models/Statistics/StatisticFilter.php new file mode 100644 index 00000000..bc656d10 --- /dev/null +++ b/code/app/Models/Statistics/StatisticFilter.php @@ -0,0 +1,34 @@ +belongsTo(Statistic::class); + } +} \ No newline at end of file diff --git a/code/app/Models/Statistics/TargetStatistic.php b/code/app/Models/Statistics/TargetStatistic.php new file mode 100644 index 00000000..09a8e6f9 --- /dev/null +++ b/code/app/Models/Statistics/TargetStatistic.php @@ -0,0 +1,56 @@ + 'array', + 'value' => 'float', + ]; + + /** + * The target model that this statistic belongs to + * + * @return MorphTo + */ + public function target(): MorphTo + { + return $this->morphTo(); + } + + /** + * The statistic that this belongs to + * + * @return BelongsTo + */ + public function statistic(): BelongsTo + { + return $this->belongsTo(Statistic::class); + } +} \ No newline at end of file diff --git a/code/app/Policies/Statistics/StatisticPolicy.php b/code/app/Policies/Statistics/StatisticPolicy.php new file mode 100644 index 00000000..9f14a5fb --- /dev/null +++ b/code/app/Policies/Statistics/StatisticPolicy.php @@ -0,0 +1,79 @@ +hasRole([ + Role::CONTENT_EDITOR, + Role::SUPPORT_STAFF, + ]); + } + + /** + * Only logged in users can create new statistic filters + * + * @param User $loggedInUser + * @return bool + */ + public function create(User $loggedInUser) + { + return $loggedInUser->hasRole([ + Role::CONTENT_EDITOR, + ]); + } + + /** + * Only logged in users can update statistic filters + * + * @param User $loggedInUser + * @return bool + */ + public function update(User $loggedInUser) + { + return $loggedInUser->hasRole([ + Role::CONTENT_EDITOR, + ]); + } + + /** + * Only logged in users can delete statistic filters + * + * @param User $loggedInUser + * @return bool + */ + public function delete(User $loggedInUser) + { + return $loggedInUser->hasRole([ + Role::CONTENT_EDITOR, + ]); + } +} \ No newline at end of file diff --git a/code/app/Providers/AppRepositoryProvider.php b/code/app/Providers/AppRepositoryProvider.php index 688ef37b..5c12b4c6 100644 --- a/code/app/Providers/AppRepositoryProvider.php +++ b/code/app/Providers/AppRepositoryProvider.php @@ -38,6 +38,5 @@ public function appMorphMaps(): array */ public function registerApp(): void { - } } \ No newline at end of file diff --git a/code/app/Providers/EventServiceProvider.php b/code/app/Providers/EventServiceProvider.php index 005e9ca7..f11c5e5f 100644 --- a/code/app/Providers/EventServiceProvider.php +++ b/code/app/Providers/EventServiceProvider.php @@ -18,8 +18,7 @@ class EventServiceProvider extends BaseEventServiceProvider */ public function getAppListenerMapping(): array { - return [ - ]; + return []; } /** diff --git a/code/database/factories/Statistics/StatisticFactory.php b/code/database/factories/Statistics/StatisticFactory.php new file mode 100644 index 00000000..c58b8b66 --- /dev/null +++ b/code/database/factories/Statistics/StatisticFactory.php @@ -0,0 +1,36 @@ + $this->faker->word, + 'model' => $this->faker->word, + 'relation' => $this->faker->word, + 'public' => $this->faker->boolean, + ]; + } +} \ No newline at end of file diff --git a/code/database/factories/Statistics/StatisticFilterFactory.php b/code/database/factories/Statistics/StatisticFilterFactory.php new file mode 100644 index 00000000..2aec733d --- /dev/null +++ b/code/database/factories/Statistics/StatisticFilterFactory.php @@ -0,0 +1,37 @@ + Statistic::factory()->create()->id, + 'field' => $this->faker->word, + 'operator' => $this->faker->randomElement(['=', '>', '<', '>=', '<=', '!=']), + 'value' => $this->faker->word, + ]; + } +} \ No newline at end of file diff --git a/code/database/factories/Statistics/TargetStatisticFactory.php b/code/database/factories/Statistics/TargetStatisticFactory.php new file mode 100644 index 00000000..f85a05f9 --- /dev/null +++ b/code/database/factories/Statistics/TargetStatisticFactory.php @@ -0,0 +1,55 @@ + User::factory(), + 'target_type' => User::class, + 'statistic_id' => Statistic::factory(), + 'value' => $this->faker->randomFloat(2, 0, 1000), + 'result' => null, + ]; + } + + /** + * Configure the factory to use a specific target model. + * + * @param mixed $model The model instance or factory + * @param string $type The class name of the target type + * @return Factory + */ + public function forTarget(mixed $model, string $type): Factory + { + return $this->state(function (array $attributes) use ($model, $type) { + return [ + 'target_id' => $model, + 'target_type' => $type, + ]; + }); + } +} \ No newline at end of file diff --git a/code/database/migrations/2025_04_30_000000_create_statistics_tables.php b/code/database/migrations/2025_04_30_000000_create_statistics_tables.php new file mode 100644 index 00000000..3eaaf842 --- /dev/null +++ b/code/database/migrations/2025_04_30_000000_create_statistics_tables.php @@ -0,0 +1,93 @@ +id(); + $table->string('name'); + $table->string('model'); + $table->string('relation'); + $table->boolean('public')->default(false); + $table->timestamps(); + $table->softDeletes(); + }); + + // Create statistic filters table + Schema::create('statistic_filters', function (Blueprint $table) { + $table->id(); + $table->foreignId('statistic_id') + ->constrained('statistics') + ->onDelete('cascade'); + $table->string('field'); + $table->string('operator'); + $table->string('value')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + + // Create target statistics table + Schema::create('target_statistics', function (Blueprint $table) { + $table->id(); + $table->foreignId('statistic_id') + ->constrained('statistics') + ->onDelete('cascade'); + $table->morphs('target'); + $table->json('result')->nullable(); + $table->float('value')->default(0); + $table->timestamps(); + $table->softDeletes(); + }); + + // Add Content Editor role + DB::table('roles')->insert([ + 'id' => Role::CONTENT_EDITOR, + 'name' => 'Content Editor', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + // Add Support Staff role + DB::table('roles')->insert([ + 'id' => Role::SUPPORT_STAFF, + 'name' => 'Support Staff', + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down(): void + { + // Remove roles + DB::table('roles')->whereIn('id', [ + Role::CONTENT_EDITOR, + Role::SUPPORT_STAFF, + ])->delete(); + + Schema::dropIfExists('target_statistics'); + Schema::dropIfExists('statistic_filters'); + Schema::dropIfExists('statistics'); + } +} \ No newline at end of file diff --git a/code/routes/core.php b/code/routes/core.php index 08c40354..f2165d05 100644 --- a/code/routes/core.php +++ b/code/routes/core.php @@ -230,4 +230,13 @@ 'index' ] ]); + + /** + * Statistics Context + */ + Route::resource('statistics', 'StatisticController', [ + 'only' => [ + 'index', 'store', 'show', 'update', 'destroy', + ] + ]); }); diff --git a/code/tests/Athenia/Feature/Http/Collection/CollectionUpdateTest.php b/code/tests/Athenia/Feature/Http/Collection/CollectionUpdateTest.php index b9ff95b4..361db519 100644 --- a/code/tests/Athenia/Feature/Http/Collection/CollectionUpdateTest.php +++ b/code/tests/Athenia/Feature/Http/Collection/CollectionUpdateTest.php @@ -226,7 +226,9 @@ public function testPatchFailsModelsDoNotExistFields(): void $response->assertJson([ 'message' => 'Sorry, something went wrong.', 'errors' => [ - 'collection_item_order.0' => ['The selected collection_item_order.0 is invalid.'], + 'collection_item_order.0' => [ + 'The selected collection_item_order.0 is invalid.', + ], ] ]); } diff --git a/code/tests/Athenia/Feature/Http/Statistics/StatisticCreateTest.php b/code/tests/Athenia/Feature/Http/Statistics/StatisticCreateTest.php new file mode 100644 index 00000000..9f2e4ab7 --- /dev/null +++ b/code/tests/Athenia/Feature/Http/Statistics/StatisticCreateTest.php @@ -0,0 +1,166 @@ +setupDatabase(); + } + + public function testNotLoggedInUserBlocked() + { + $response = $this->json('POST', $this->route); + + $response->assertStatus(403); + } + + public function testNotAuthorizedUserBlocked() + { + $this->actAsUser(); + $response = $this->json('POST', $this->route); + + $response->assertStatus(403); + } + + public function testCreateSuccessWithoutStatisticFilters() + { + $this->actAs(Role::CONTENT_EDITOR); + + $properties = [ + 'name' => 'Test Statistic', + 'model' => 'collection', + 'relation' => 'collectionItems', + 'public' => true, + ]; + + $response = $this->json('POST', $this->route, $properties); + $response->assertStatus(201); + $response->assertJsonFragment($properties); + } + + public function testCreateSuccessWithStatisticFilters() + { + $this->actAs(Role::CONTENT_EDITOR); + + $properties = [ + 'name' => 'Test Statistic', + 'model' => 'collection', + 'relation' => 'collectionItems', + 'public' => true, + 'statistic_filters' => [ + [ + 'field' => 'active', + 'operator' => '=', + 'value' => '1', + ], + ], + ]; + + $response = $this->json('POST', $this->route, $properties); + $response->assertStatus(201); + unset($properties['statistic_filters']); + $response->assertJsonFragment($properties); + + /** @var Statistic $created */ + $created = Statistic::first(); + $this->assertCount(1, $created->statisticFilters); + } + + public function testCreateFailsValidation() + { + $this->actAs(Role::CONTENT_EDITOR); + + $response = $this->json('POST', $this->route, [ + 'name' => '', + 'model' => '', + 'relation' => '', + 'public' => 'yes', + 'statistic_filters' => 'hi', + ]); + + $response->assertStatus(400); + $response->assertJsonValidationErrors([ + 'name' => ['The name field is required.'], + 'model' => ['The model field is required.'], + 'relation' => ['The relation field is required.'], + 'public' => ['The public field must be true or false.'], + 'statistic_filters' => ['The statistic filters must be an array.'], + ]); + } + + public function testCreateFailsStatisticFilterValidation() + { + $this->actAs(Role::CONTENT_EDITOR); + + $response = $this->json('POST', $this->route, [ + 'name' => 'Test', + 'model' => 'collection', + 'relation' => '', + 'statistic_filters' => [ + 'not an array', + ], + ]); + + $response->assertStatus(400); + $response->assertJsonValidationErrors([ + 'statistic_filters.0' => ['The statistic_filters.0 must be an array.'], + 'statistic_filters.0.field' => ['The statistic_filters.0.field field is required.'], + 'statistic_filters.0.operator' => ['The statistic_filters.0.operator field is required.'], + ]); + + $response = $this->json('POST', $this->route, [ + 'name' => 'Test', + 'model' => 'collection', + 'relation' => 'collectionItems', + 'statistic_filters' => [ + [], + ], + ]); + + $response->assertStatus(400); + $response->assertJsonValidationErrors([ + 'statistic_filters.0.field' => ['The statistic_filters.0.field field is required.'], + 'statistic_filters.0.operator' => ['The statistic_filters.0.operator field is required.'], + ]); + + $response = $this->json('POST', $this->route, [ + 'name' => 'Test', + 'model' => 'collection', + 'relation' => 'collectionItems', + 'statistic_filters' => [ + [ + 'field' => 123, + 'operator' => 456, + 'value' => 789, + ], + ], + ]); + + $response->assertStatus(400); + $response->assertJsonValidationErrors([ + 'statistic_filters.0.field' => ['The statistic_filters.0.field must be a string.'], + 'statistic_filters.0.operator' => ['The statistic_filters.0.operator must be a string.'], + 'statistic_filters.0.value' => ['The statistic_filters.0.value must be a string.'], + ]); + } +} \ No newline at end of file diff --git a/code/tests/Athenia/Feature/Http/Statistics/StatisticDeleteTest.php b/code/tests/Athenia/Feature/Http/Statistics/StatisticDeleteTest.php new file mode 100644 index 00000000..78b69806 --- /dev/null +++ b/code/tests/Athenia/Feature/Http/Statistics/StatisticDeleteTest.php @@ -0,0 +1,78 @@ +setupDatabase(); + $this->mockApplicationLog(); + } + + public function testNotLoggedInUserBlocked() + { + $model = Statistic::factory()->create(); + $response = $this->json('DELETE', '/v1/statistics/' . $model->id); + $response->assertStatus(403); + } + + public function testNonAdminUserBlocked() + { + foreach ($this->rolesWithoutAdmins([Role::CONTENT_EDITOR, Role::SUPPORT_STAFF]) as $role) { + $this->actAs($role); + $model = Statistic::factory()->create(); + $response = $this->json('DELETE', '/v1/statistics/' . $model->id); + $response->assertStatus(403); + } + } + + public function testDeleteSingle() + { + $this->actAs(Role::CONTENT_EDITOR); + + $model = Statistic::factory()->create(); + + $response = $this->json('DELETE', '/v1/statistics/' . $model->id); + + $response->assertStatus(204); + $this->assertEquals(0, Statistic::count()); + } + + public function testDeleteSingleInvalidIdFails() + { + $this->actAs(Role::CONTENT_EDITOR); + + $response = $this->json('DELETE', '/v1/statistics/a') + ->assertExactJson([ + 'message' => 'This path was not found.', + ]); + $response->assertStatus(404); + } + + public function testDeleteSingleNotFoundFails() + { + $this->actAs(Role::CONTENT_EDITOR); + + $response = $this->json('DELETE', '/v1/statistics/1') + ->assertExactJson([ + 'message' => 'This item was not found.' + ]); + $response->assertStatus(404); + } +} \ No newline at end of file diff --git a/code/tests/Athenia/Feature/Http/Statistics/StatisticIndexTest.php b/code/tests/Athenia/Feature/Http/Statistics/StatisticIndexTest.php new file mode 100644 index 00000000..fa820455 --- /dev/null +++ b/code/tests/Athenia/Feature/Http/Statistics/StatisticIndexTest.php @@ -0,0 +1,100 @@ +setupDatabase(); + $this->mockApplicationLog(); + } + + public function testNotLoggedInUserBlocked() + { + $response = $this->json('GET', '/v1/statistics'); + $response->assertStatus(403); + } + + public function testGetPaginationEmpty() + { + $this->actAs(Role::APP_USER); + $response = $this->json('GET', '/v1/statistics'); + $response->assertStatus(200); + $response->assertJson([ + 'total' => 0, + 'data' => [] + ]); + } + + public function testGetPaginationResult() + { + $this->actAs(Role::APP_USER); + Statistic::factory()->count(15)->create(); + + // first page + $response = $this->json('GET', '/v1/statistics'); + $response->assertJson([ + 'total' => 15, + 'current_page' => 1, + 'per_page' => 10, + 'from' => 1, + 'to' => 10, + 'last_page' => 2 + ]) + ->assertJsonStructure([ + 'data' => [ + '*' => array_keys((new Statistic())->toArray()) + ] + ]); + $response->assertStatus(200); + + // second page + $response = $this->json('GET', '/v1/statistics?page=2'); + $response->assertJson([ + 'total' => 15, + 'current_page' => 2, + 'per_page' => 10, + 'from' => 11, + 'to' => 15, + 'last_page' => 2 + ]) + ->assertJsonStructure([ + 'data' => [ + '*' => array_keys((new Statistic())->toArray()) + ] + ]); + $response->assertStatus(200); + + // page with limit + $response = $this->json('GET', '/v1/statistics?page=2&limit=5'); + $response->assertJson([ + 'total' => 15, + 'current_page' => 2, + 'per_page' => 5, + 'from' => 6, + 'to' => 10, + 'last_page' => 3 + ]) + ->assertJsonStructure([ + 'data' => [ + '*' => array_keys((new Statistic())->toArray()) + ] + ]); + $response->assertStatus(200); + } +} \ No newline at end of file diff --git a/code/tests/Athenia/Feature/Http/Statistics/StatisticUpdateTest.php b/code/tests/Athenia/Feature/Http/Statistics/StatisticUpdateTest.php new file mode 100644 index 00000000..dac65bce --- /dev/null +++ b/code/tests/Athenia/Feature/Http/Statistics/StatisticUpdateTest.php @@ -0,0 +1,263 @@ +setupDatabase(); + $this->mockApplicationLog(); + } + + public function testNotLoggedInUserBlocked() + { + $statistic = Statistic::factory()->create(); + $response = $this->json('PATCH', static::BASE_ROUTE . $statistic->id); + $response->assertStatus(403); + } + + public function testNotAdminUserBlocked() + { + foreach ($this->rolesWithoutAdmins([Role::CONTENT_EDITOR, Role::SUPPORT_STAFF]) as $role) { + $this->actAs($role); + $statistic = Statistic::factory()->create(); + $response = $this->json('PATCH', static::BASE_ROUTE . $statistic->id); + $response->assertStatus(403); + } + } + + public function testPatchSuccessful() + { + $this->actAs(Role::SUPER_ADMIN); + + /** @var Statistic $statistic */ + $statistic = Statistic::factory()->create([ + 'name' => 'Test Stat', + ]); + + $data = [ + 'name' => 'Test Statistic', + ]; + + $response = $this->json('PATCH', static::BASE_ROUTE . $statistic->id, $data); + $response->assertStatus(200); + $response->assertJson($data); + + /** @var Statistic $updated */ + $updated = Statistic::find($statistic->id); + + $this->assertEquals('Test Statistic', $updated->name); + } + + public function testPatchNotFoundFails() + { + $this->actAs(Role::SUPER_ADMIN); + + $response = $this->json('PATCH', static::BASE_ROUTE . '5') + ->assertExactJson([ + 'message' => 'This item was not found.' + ]); + $response->assertStatus(404); + } + + public function testPatchInvalidIdFails() + { + $this->actAs(Role::SUPER_ADMIN); + + $response = $this->json('PATCH', static::BASE_ROUTE . '/b') + ->assertExactJson([ + 'message' => 'This path was not found.', + ]); + $response->assertStatus(404); + } + + public function testPatchSuccessfulNoFields() + { + $this->actAs(Role::SUPER_ADMIN); + + $statistic = Statistic::factory()->create([ + 'name' => 'Test Gift Pack', + ]); + + $response = $this->json('PATCH', static::BASE_ROUTE . $statistic->id, []); + + $response->assertStatus(200); + } + + public function testPatchFailsIncludingNotPresetFields() + { + $statistic = Statistic::factory()->create(); + + $this->actAs(Role::SUPER_ADMIN); + $response = $this->json('PATCH', static::BASE_ROUTE . $statistic->id, [ + 'model' => 'character', + 'relation' => 'active' + ]); + + $response->assertStatus(400); + $response->assertJson([ + 'message' => 'Sorry, something went wrong.', + 'errors' => [ + 'model' => ['The model field is not allowed or can not be set for this request.'], + 'relation' => ['The relation field is not allowed or can not be set for this request.'], + ] + ]); + } + + public function testPatchFailsInvalidStringFields() + { + $this->actAs(Role::SUPER_ADMIN); + + $data = [ + 'name' => 5, + ]; + + $statistic = Statistic::factory()->create(); + + $response = $this->json('PATCH', static::BASE_ROUTE . $statistic->id, $data); + + $response->assertStatus(400); + $response->assertJson([ + 'message' => 'Sorry, something went wrong.', + 'errors' => [ + 'name' => ['The name must be a string.'], + ] + ]); + } + + public function testPatchFailsInvalidBooleanFields() + { + $this->actAs(Role::SUPER_ADMIN); + + $data = [ + 'public' => 'hi', + ]; + + $statistic = Statistic::factory()->create(); + + $response = $this->json('PATCH', static::BASE_ROUTE . $statistic->id, $data); + + $response->assertStatus(400); + $response->assertJson([ + 'message' => 'Sorry, something went wrong.', + 'errors' => [ + 'public' => ['The public field must be true or false.'], + ] + ]); + } + + public function testPatchFailsInvalidArrayFields() + { + $this->actAs(Role::SUPER_ADMIN); + + $data = [ + 'statistic_filters' => 'hi', + ]; + + $statistic = Statistic::factory()->create(); + + $response = $this->json('PATCH', static::BASE_ROUTE . $statistic->id, $data); + + $response->assertStatus(400); + $response->assertJson([ + 'message' => 'Sorry, something went wrong.', + 'errors' => [ + 'statistic_filters' => ['The statistic filters must be an array.'], + ] + ]); + } + + public function testPatchFailsInvalidFilterArrayFields() + { + $this->actAs(Role::SUPER_ADMIN); + + $data = [ + 'statistic_filters' => [ + 'ho' + ], + ]; + + $statistic = Statistic::factory()->create(); + + $response = $this->json('PATCH', static::BASE_ROUTE . $statistic->id, $data); + + $response->assertStatus(400); + $response->assertJson([ + 'message' => 'Sorry, something went wrong.', + 'errors' => [ + 'statistic_filters.0' => ['The statistic_filters.0 must be an array.'], + ] + ]); + } + + public function testPatchFailsInvalidFilterRequiredFields() + { + $this->actAs(Role::SUPER_ADMIN); + + $data = [ + 'statistic_filters' => [ + [] + ], + ]; + + $statistic = Statistic::factory()->create(); + + $response = $this->json('PATCH', static::BASE_ROUTE . $statistic->id, $data); + + $response->assertStatus(400); + $response->assertJson([ + 'message' => 'Sorry, something went wrong.', + 'errors' => [ + 'statistic_filters.0.field' => ['The statistic_filters.0.field field is required.'], + 'statistic_filters.0.operator' => ['The statistic_filters.0.operator field is required.'], + ] + ]); + } + + public function testPatchFailsInvalidFilterStringFields() + { + $this->actAs(Role::SUPER_ADMIN); + + $data = [ + 'statistic_filters' => [ + [ + 'field' => 1, + 'operator' => 1, + 'value' => 1, + ] + ], + ]; + + $statistic = Statistic::factory()->create(); + + $response = $this->json('PATCH', static::BASE_ROUTE . $statistic->id, $data); + + $response->assertStatus(400); + $response->assertJson([ + 'message' => 'Sorry, something went wrong.', + 'errors' => [ + 'statistic_filters.0.field' => ['The statistic_filters.0.field must be a string.'], + 'statistic_filters.0.operator' => ['The statistic_filters.0.operator must be a string.'], + 'statistic_filters.0.value' => ['The statistic_filters.0.value must be a string.'], + ] + ]); + } +} \ No newline at end of file diff --git a/code/tests/Athenia/Feature/Http/Statistics/StatisticViewTest.php b/code/tests/Athenia/Feature/Http/Statistics/StatisticViewTest.php new file mode 100644 index 00000000..3105204b --- /dev/null +++ b/code/tests/Athenia/Feature/Http/Statistics/StatisticViewTest.php @@ -0,0 +1,78 @@ +setupDatabase(); + $this->mockApplicationLog(); + } + + public function testNotLoggedInUserBlocked() + { + $model = Statistic::factory()->create(); + $response = $this->json('GET', '/v1/statistics/' . $model->id); + $response->assertStatus(403); + } + + public function testInvalidRoleUserBlocked() + { + foreach ($this->rolesWithoutAdmins([Role::CONTENT_EDITOR, Role::SUPPORT_STAFF]) as $role) { + $this->actAs($role); + $model = Statistic::factory()->create(); + $response = $this->json('GET', '/v1/statistics/' . $model->id); + $response->assertStatus(403); + } + } + + public function testGetSingleSuccess() + { + $this->actAs(Role::CONTENT_EDITOR); + /** @var Statistic $model */ + $model = Statistic::factory()->create([ + 'id' => 1, + ]); + + $response = $this->json('GET', '/v1/statistics/1?expands[statisticFilters]=*'); + + $response->assertStatus(200); + $response->assertJson($model->toArray()); + } + + public function testGetSingleNotFoundFails() + { + $this->actAs(Role::CONTENT_EDITOR); + $response = $this->json('GET', '/v1/statistics/1') + ->assertExactJson([ + 'message' => 'This item was not found.' + ]); + $response->assertStatus(404); + } + + public function testGetSingleInvalidIdFails() + { + $this->actAs(Role::CONTENT_EDITOR); + $response = $this->json('GET', '/v1/statistics/a') + ->assertExactJson([ + 'message' => 'This path was not found.' + ]); + $response->assertStatus(404); + } +} \ No newline at end of file diff --git a/code/tests/Athenia/Integration/Policies/Statistics/StatisticPolicyTest.php b/code/tests/Athenia/Integration/Policies/Statistics/StatisticPolicyTest.php new file mode 100644 index 00000000..2051e21b --- /dev/null +++ b/code/tests/Athenia/Integration/Policies/Statistics/StatisticPolicyTest.php @@ -0,0 +1,108 @@ +assertTrue($policy->all(new User())); + } + + public function testViewFailsIncorrectRole() + { + $policy = new StatisticPolicy(); + + foreach ($this->rolesWithoutAdmins([Role::CONTENT_EDITOR, Role::SUPPORT_STAFF]) as $role) { + $user = $this->getUserOfRole($role); + + $this->assertFalse($policy->view($user)); + } + } + + public function testViewPassesCorrectRole() + { + $policy = new StatisticPolicy(); + + foreach ([Role::CONTENT_EDITOR, Role::SUPPORT_STAFF] as $role) { + $user = $this->getUserOfRole($role); + + $this->assertTrue($policy->view($user)); + } + } + + public function testCreateFailsIncorrectRole() + { + $policy = new StatisticPolicy(); + + foreach ($this->rolesWithoutAdmins([Role::CONTENT_EDITOR]) as $role) { + $user = $this->getUserOfRole($role); + + $this->assertFalse($policy->create($user)); + } + } + + public function testCreatePassesCorrectRole() + { + $policy = new StatisticPolicy(); + + $user = $this->getUserOfRole(Role::CONTENT_EDITOR); + + $this->assertTrue($policy->create($user)); + } + + public function testUpdateFailsIncorrectRole() + { + $policy = new StatisticPolicy(); + + foreach ($this->rolesWithoutAdmins([Role::CONTENT_EDITOR]) as $role) { + $user = $this->getUserOfRole($role); + + $this->assertFalse($policy->update($user)); + } + } + + public function testUpdatePassesCorrectRole() + { + $policy = new StatisticPolicy(); + + $user = $this->getUserOfRole(Role::CONTENT_EDITOR); + + $this->assertTrue($policy->update($user)); + } + + public function testDeleteFailsIncorrectRole() + { + $policy = new StatisticPolicy(); + + foreach ($this->rolesWithoutAdmins([Role::CONTENT_EDITOR]) as $role) { + $user = $this->getUserOfRole($role); + + $this->assertFalse($policy->delete($user)); + } + } + + public function testDeletePassesCorrectRole() + { + $policy = new StatisticPolicy(); + + $user = $this->getUserOfRole(Role::CONTENT_EDITOR); + + $this->assertTrue($policy->delete($user)); + } +} \ No newline at end of file diff --git a/code/tests/Athenia/Integration/Repositories/Statistics/StatisticFilterRepositoryTest.php b/code/tests/Athenia/Integration/Repositories/Statistics/StatisticFilterRepositoryTest.php new file mode 100644 index 00000000..5445407e --- /dev/null +++ b/code/tests/Athenia/Integration/Repositories/Statistics/StatisticFilterRepositoryTest.php @@ -0,0 +1,107 @@ +setupDatabase(); + $this->mockApplicationLog(); + + $this->repository = new StatisticFilterRepository( + new StatisticFilter(), + $this->getGenericLogMock() + ); + } + + public function testFindAllReturnsCollection() + { + StatisticFilter::factory()->count(5)->create(); + + $models = $this->repository->findAll(); + + $this->assertCount(5, $models); + } + + public function testFindReturnsModel() + { + $model = StatisticFilter::factory()->create(); + + $foundModel = $this->repository->findOrFail($model->id); + + $this->assertEquals($model->id, $foundModel->id); + } + + public function testFindOrFailThrowsException() + { + StatisticFilter::factory()->create(['id' => 2]); + + $this->expectException(\Exception::class); + + $this->repository->findOrFail(1); + } + + public function testCreateSuccess() + { + /** @var Statistic $statistic */ + $statistic = Statistic::factory()->create(); + + /** @var StatisticFilter $statisticFilter */ + $statisticFilter = $this->repository->create([ + 'field' => 'active', + 'operator' => '=', + 'value' => '1', + ], $statistic); + + $this->assertCount(1, StatisticFilter::all()); + $this->assertEquals($statisticFilter->statistic_id, $statistic->id); + $this->assertEquals('active', $statisticFilter->field); + $this->assertEquals('=', $statisticFilter->operator); + $this->assertEquals('1', $statisticFilter->value); + } + + public function testUpdateSuccess() + { + /** @var StatisticFilter $statisticFilter */ + $statisticFilter = StatisticFilter::factory()->create([ + 'field' => 'active', + ]); + + /** @var StatisticFilter $result */ + $result = $this->repository->update($statisticFilter, [ + 'field' => 'type', + ]); + + $this->assertEquals('type', $result->field); + } + + public function testDeleteSuccess() + { + $model = StatisticFilter::factory()->create(); + + $this->repository->delete($model); + + $this->assertNull(StatisticFilter::find($model->id)); + } +} \ No newline at end of file diff --git a/code/tests/Athenia/Integration/Repositories/Statistics/StatisticRepositoryTest.php b/code/tests/Athenia/Integration/Repositories/Statistics/StatisticRepositoryTest.php new file mode 100644 index 00000000..e86eff5a --- /dev/null +++ b/code/tests/Athenia/Integration/Repositories/Statistics/StatisticRepositoryTest.php @@ -0,0 +1,234 @@ +setupDatabase(); + + $this->dispatcher = $this->createMock(Dispatcher::class); + $this->statisticFilterRepository = app(StatisticFilterRepository::class); + $this->repository = new StatisticRepository( + app(Statistic::class), + $this->getGenericLogMock(), + $this->statisticFilterRepository, + $this->dispatcher + ); + } + + public function testFindAllReturnsCollection() + { + foreach (Statistic::all() as $model) { + $model->delete(); + } + Statistic::factory()->count(5)->create(); + + $models = $this->repository->findAll(); + + $this->assertCount(5, $models); + } + + public function testFindAllWithFilterReturnsCollection() + { + foreach (Statistic::all() as $model) { + $model->delete(); + } + Statistic::factory()->create(['id' => 1]); + Statistic::factory()->count(4)->create(); + + $models = $this->repository->findAll(['id' => 1]); + + $this->assertCount(1, $models); + } + + public function testFindReturnsModel() + { + foreach (Statistic::all() as $model) { + $model->delete(); + } + $model = Statistic::factory()->create(); + + $foundModel = $this->repository->findOrFail($model->id); + + $this->assertEquals($model->id, $foundModel->id); + } + + public function testFindOrFailThrowsException() + { + foreach (Statistic::all() as $model) { + $model->delete(); + } + Statistic::factory()->create(['id' => 35]); + + $this->expectException(\Exception::class); + + $this->repository->findOrFail(1); + } + + public function testCreateStatisticWithFilters() + { + $data = [ + 'name' => 'Test Statistic', + 'model' => 'User', + 'relation' => 'contacts', + 'public' => true, + 'statistic_filters' => [ + [ + 'field' => 'active', + 'operator' => '=', + 'value' => '1', + ], + [ + 'field' => 'type', + 'operator' => '=', + 'value' => 'character', + ], + ], + ]; + + $this->dispatcher->expects($this->once()) + ->method('dispatch') + ->with($this->callback(function ($event) { + return $event instanceof StatisticCreatedEvent; + })); + + $statistic = $this->repository->create($data); + + $this->assertInstanceOf(Statistic::class, $statistic); + $this->assertEquals('Test Statistic', $statistic->name); + $this->assertEquals('User', $statistic->model); + $this->assertEquals('contacts', $statistic->relation); + $this->assertTrue($statistic->public); + + $this->assertCount(2, $statistic->statisticFilters); + $this->assertInstanceOf(Collection::class, $statistic->statisticFilters); + + $filter1 = $statistic->statisticFilters->first(); + $this->assertInstanceOf(StatisticFilter::class, $filter1); + $this->assertEquals('active', $filter1->field); + $this->assertEquals('=', $filter1->operator); + $this->assertEquals('1', $filter1->value); + + $filter2 = $statistic->statisticFilters->last(); + $this->assertInstanceOf(StatisticFilter::class, $filter2); + $this->assertEquals('type', $filter2->field); + $this->assertEquals('=', $filter2->operator); + $this->assertEquals('character', $filter2->value); + } + + public function testUpdateStatisticWithFilters() + { + $statistic = Statistic::factory()->create(); + $filter1 = StatisticFilter::factory()->create([ + 'statistic_id' => $statistic->id, + 'field' => 'active', + 'operator' => '=', + 'value' => '0', + ]); + $filter2 = StatisticFilter::factory()->create([ + 'statistic_id' => $statistic->id, + 'field' => 'type', + 'operator' => '=', + 'value' => 'user', + ]); + + $data = [ + 'name' => 'Updated Statistic', + 'public' => false, + 'statistic_filters' => [ + [ + 'id' => $filter1->id, + 'field' => 'active', + 'operator' => '=', + 'value' => '1', + ], + [ + 'id' => $filter2->id, + 'field' => 'type', + 'operator' => '=', + 'value' => 'character', + ], + ], + ]; + + $this->dispatcher->expects($this->once()) + ->method('dispatch') + ->with($this->callback(function ($event) { + return $event instanceof StatisticUpdatedEvent; + })); + + $updatedStatistic = $this->repository->update($statistic, $data); + + $this->assertInstanceOf(Statistic::class, $updatedStatistic); + $this->assertEquals('Updated Statistic', $updatedStatistic->name); + $this->assertFalse($updatedStatistic->public); + + $this->assertCount(2, $updatedStatistic->statisticFilters); + $this->assertInstanceOf(Collection::class, $updatedStatistic->statisticFilters); + + $filter1 = $updatedStatistic->statisticFilters->first(); + $this->assertInstanceOf(StatisticFilter::class, $filter1); + $this->assertEquals('active', $filter1->field); + $this->assertEquals('=', $filter1->operator); + $this->assertEquals('1', $filter1->value); + + $filter2 = $updatedStatistic->statisticFilters->last(); + $this->assertInstanceOf(StatisticFilter::class, $filter2); + $this->assertEquals('type', $filter2->field); + $this->assertEquals('=', $filter2->operator); + $this->assertEquals('character', $filter2->value); + } + + public function testDeleteSuccess() + { + $statistic = Statistic::factory()->create(); + + $this->dispatcher->expects($this->once()) + ->method('dispatch') + ->with($this->callback(function ($event) { + return $event instanceof StatisticDeletedEvent; + })); + + $this->repository->delete($statistic); + + $this->assertNull(Statistic::find($statistic->id)); + } +} \ No newline at end of file diff --git a/code/tests/Athenia/Integration/Repositories/Statistics/TargetStatisticRepositoryTest.php b/code/tests/Athenia/Integration/Repositories/Statistics/TargetStatisticRepositoryTest.php new file mode 100644 index 00000000..76e2e8df --- /dev/null +++ b/code/tests/Athenia/Integration/Repositories/Statistics/TargetStatisticRepositoryTest.php @@ -0,0 +1,200 @@ +setupDatabase(); + + $this->repository = new TargetStatisticRepository( + new TargetStatistic(), + $this->getGenericLogMock() + ); + } + + public function testFindAllReturnsCollection() + { + foreach (TargetStatistic::all() as $model) { + $model->delete(); + } + TargetStatistic::factory()->count(5)->create(); + + $models = $this->repository->findAll(); + + $this->assertCount(5, $models); + $this->assertInstanceOf(\Illuminate\Contracts\Pagination\LengthAwarePaginator::class, $models); + } + + public function testFindAllWithFilterReturnsCollection() + { + foreach (TargetStatistic::all() as $model) { + $model->delete(); + } + TargetStatistic::factory()->create(['id' => 1]); + TargetStatistic::factory()->count(4)->create(); + + $models = $this->repository->findAll(['id' => 1]); + + $this->assertCount(1, $models); + $this->assertInstanceOf(\Illuminate\Contracts\Pagination\LengthAwarePaginator::class, $models); + } + + public function testFindReturnsModel() + { + foreach (TargetStatistic::all() as $model) { + $model->delete(); + } + $model = TargetStatistic::factory()->create(); + + $foundModel = $this->repository->findOrFail($model->id); + + $this->assertEquals($model->id, $foundModel->id); + } + + public function testFindOrFailThrowsException() + { + foreach (TargetStatistic::all() as $model) { + $model->delete(); + } + TargetStatistic::factory()->create(['id' => 35]); + + $this->expectException(\Exception::class); + + $this->repository->findOrFail(1); + } + + public function testCreateForTargetSuccess() + { + $collection = Collection::factory()->create(); + $statistic = Statistic::factory()->create(); + + $targetStatistic = $this->repository->createForTarget($collection, [ + 'statistic_id' => $statistic->id, + 'value' => 42.5, + 'result' => ['type' => 'test'], + ]); + + $this->assertInstanceOf(TargetStatistic::class, $targetStatistic); + $this->assertEquals($collection->id, $targetStatistic->target_id); + $this->assertEquals('collection', $targetStatistic->target_type); + $this->assertEquals($statistic->id, $targetStatistic->statistic_id); + $this->assertEquals(42.5, $targetStatistic->value); + $this->assertEquals(['type' => 'test'], $targetStatistic->result); + } + + public function testFindAllForTargetSuccess() + { + $collection = Collection::factory()->create(); + TargetStatistic::factory()->count(3)->create([ + 'target_id' => $collection->id, + 'target_type' => 'collection', + ]); + // Create some stats for another collection to ensure filtering works + TargetStatistic::factory()->count(2)->create(); + + $results = $this->repository->findAllForTarget($collection); + + $this->assertCount(3, $results); + $this->assertInstanceOf(EloquentCollection::class, $results); + foreach ($results as $stat) { + $this->assertEquals($collection->id, $stat->target_id); + $this->assertEquals('collection', $stat->target_type); + } + } + + public function testFindForTargetSuccess() + { + $collection = Collection::factory()->create(); + $statistic = Statistic::factory()->create(); + TargetStatistic::factory()->create([ + 'target_id' => $collection->id, + 'target_type' => 'collection', + 'statistic_id' => $statistic->id, + ]); + + $result = $this->repository->findForTarget($collection, $statistic->id); + + $this->assertNotNull($result); + $this->assertInstanceOf(TargetStatistic::class, $result); + $this->assertEquals($collection->id, $result->target_id); + $this->assertEquals($statistic->id, $result->statistic_id); + } + + public function testFindForTargetReturnsNullWhenNotFound() + { + $collection = Collection::factory()->create(); + $result = $this->repository->findForTarget($collection, 999); + + $this->assertNull($result); + } + + public function testCreateSuccess(): void + { + $statistic = Statistic::factory()->create(); + + $targetStatistic = $this->repository->create([ + 'statistic_id' => $statistic->id, + 'target_type' => User::class, + 'target_id' => 1, + 'result' => ['test' => 'value'], + 'value' => 1.0, + ]); + + $this->assertInstanceOf(TargetStatistic::class, $targetStatistic); + $this->assertEquals($statistic->id, $targetStatistic->statistic_id); + $this->assertEquals(User::class, $targetStatistic->target_type); + $this->assertEquals(1, $targetStatistic->target_id); + $this->assertEquals(['test' => 'value'], $targetStatistic->result); + $this->assertEquals(1.0, $targetStatistic->value); + } + + public function testUpdateSuccess(): void + { + $targetStatistic = TargetStatistic::factory()->create([ + 'result' => ['old' => 'value'], + 'value' => 1.0, + ]); + + $updatedStatistic = $this->repository->update($targetStatistic, [ + 'result' => ['new' => 'value'], + 'value' => 2.0, + ]); + + $this->assertInstanceOf(TargetStatistic::class, $updatedStatistic); + $this->assertEquals(['new' => 'value'], $updatedStatistic->result); + $this->assertEquals(2.0, $updatedStatistic->value); + } + + public function testDeleteSuccess() + { + $targetStatistic = TargetStatistic::factory()->create(); + + $this->repository->delete($targetStatistic); + + $this->assertNull(TargetStatistic::find($targetStatistic->id)); + } +} \ No newline at end of file diff --git a/code/tests/Athenia/Integration/Services/Statistics/StatisticSynchronizationServiceTest.php b/code/tests/Athenia/Integration/Services/Statistics/StatisticSynchronizationServiceTest.php new file mode 100644 index 00000000..2d241199 --- /dev/null +++ b/code/tests/Athenia/Integration/Services/Statistics/StatisticSynchronizationServiceTest.php @@ -0,0 +1,115 @@ +service = app(StatisticSynchronizationService::class); + } + + public function testCreateTargetStatisticsForStatistic(): void + { + // Clean up any existing collections and statistics + DB::table('collections')->delete(); + DB::table('statistics')->delete(); + + // Create two collections + $collections = Collection::factory()->count(2)->create(); + + // Create a statistic for collections + $statistic = Statistic::factory()->create([ + 'model' => 'collection' + ]); + + // Create target statistics for the statistic + $targetStatistics = $this->service->createTargetStatisticsForStatistic($statistic); + + // Assert we got a collection of target statistics + $this->assertInstanceOf(EloquentCollection::class, $targetStatistics); + $this->assertCount(2, $targetStatistics); + + // Assert each target statistic was created correctly + foreach ($targetStatistics as $targetStatistic) { + $this->assertInstanceOf(TargetStatistic::class, $targetStatistic); + $this->assertEquals($statistic->id, $targetStatistic->statistic_id); + $this->assertEquals('collection', $targetStatistic->target_type); + $this->assertTrue($collections->pluck('id')->contains($targetStatistic->target_id)); + } + } + + public function testSynchronizeTargetStatisticsWithNoExistingTargets(): void + { + // Clean up any existing statistics + DB::table('statistics')->delete(); + + // Create a collection + $collection = Collection::factory()->create(); + + // Create a statistic for collections + $statistic = Statistic::factory()->create([ + 'model' => 'collection' + ]); + + // Synchronize target statistics + $result = $this->service->synchronizeTargetStatistics($collection); + + // Assert we got a collection of target statistics + $this->assertInstanceOf(EloquentCollection::class, $result); + $this->assertCount(1, $result); + + // Assert the target statistic was created correctly + $targetStatistic = $result->first(); + $this->assertEquals($statistic->id, $targetStatistic->statistic_id); + $this->assertEquals($collection->id, $targetStatistic->target_id); + $this->assertEquals('collection', $targetStatistic->target_type); + } + + public function testSynchronizeTargetStatisticsWithExistingTargets(): void + { + // Clean up any existing statistics + DB::table('statistics')->delete(); + + // Create a collection + $collection = Collection::factory()->create(); + + // Create two statistics for collections + $existingStatistic = Statistic::factory()->create([ + 'model' => 'collection' + ]); + $newStatistic = Statistic::factory()->create([ + 'model' => 'collection' + ]); + + // Create an existing target statistic + TargetStatistic::factory()->create([ + 'statistic_id' => $existingStatistic->id, + 'target_id' => $collection->id, + 'target_type' => 'collection' + ]); + + // Synchronize target statistics + $result = $this->service->synchronizeTargetStatistics($collection); + + // Assert we got a collection with both target statistics + $this->assertInstanceOf(EloquentCollection::class, $result); + $this->assertCount(2, $result); + + // Assert both target statistics exist + $this->assertTrue($result->contains('statistic_id', $existingStatistic->id)); + $this->assertTrue($result->contains('statistic_id', $newStatistic->id)); + } +} \ No newline at end of file diff --git a/code/tests/Athenia/Unit/Events/Statistics/StatisticCreatedEventTest.php b/code/tests/Athenia/Unit/Events/Statistics/StatisticCreatedEventTest.php new file mode 100644 index 00000000..17d60475 --- /dev/null +++ b/code/tests/Athenia/Unit/Events/Statistics/StatisticCreatedEventTest.php @@ -0,0 +1,20 @@ +assertSame($statistic, $event->getStatistic()); + } +} \ No newline at end of file diff --git a/code/tests/Athenia/Unit/Events/Statistics/StatisticDeletedEventTest.php b/code/tests/Athenia/Unit/Events/Statistics/StatisticDeletedEventTest.php new file mode 100644 index 00000000..2f46c0b7 --- /dev/null +++ b/code/tests/Athenia/Unit/Events/Statistics/StatisticDeletedEventTest.php @@ -0,0 +1,20 @@ +assertSame($statistic, $event->getStatistic()); + } +} \ No newline at end of file diff --git a/code/tests/Athenia/Unit/Events/Statistics/StatisticUpdatedEventTest.php b/code/tests/Athenia/Unit/Events/Statistics/StatisticUpdatedEventTest.php new file mode 100644 index 00000000..f385ac1e --- /dev/null +++ b/code/tests/Athenia/Unit/Events/Statistics/StatisticUpdatedEventTest.php @@ -0,0 +1,23 @@ +assertSame($statistic, $event->getStatistic()); + } +} \ No newline at end of file diff --git a/code/tests/Athenia/Unit/Jobs/Statistics/ProcessTargetStatisticsJobTest.php b/code/tests/Athenia/Unit/Jobs/Statistics/ProcessTargetStatisticsJobTest.php new file mode 100644 index 00000000..a392c2b0 --- /dev/null +++ b/code/tests/Athenia/Unit/Jobs/Statistics/ProcessTargetStatisticsJobTest.php @@ -0,0 +1,90 @@ +id = 1; + $collection->name = 'Test Collection'; + + // Create a real Statistic model without saving + $statistic = new Statistic(); + $statistic->id = 1; + $statistic->name = 'Test Statistic'; + + // Create real TargetStatistic models without saving + $targetStatistics = new EloquentCollection([ + new TargetStatistic([ + 'id' => 1, + 'target_id' => $collection->id, + 'target_type' => 'collection', + 'statistic_id' => $statistic->id, + ]), + new TargetStatistic([ + 'id' => 2, + 'target_id' => $collection->id, + 'target_type' => 'collection', + 'statistic_id' => $statistic->id, + ]), + ]); + + // Associate the target statistics with the collection + $collection->setRelation('targetStatistics', $targetStatistics); + + /** @var TargetStatisticProcessingServiceContract|MockInterface $processingService */ + $processingService = Mockery::mock(TargetStatisticProcessingServiceContract::class); + + // Setup expectations for each statistic + foreach ($targetStatistics as $targetStatistic) { + $processingService->shouldReceive('processSingleTargetStatistic') + ->with($targetStatistic) + ->once(); + } + + $job = new ProcessTargetStatisticsJob($collection); + $job->handle($processingService); + } + + public function testHandleWithNoTargetStatistics(): void + { + // Create a real Collection model without saving + $collection = new Collection(); + $collection->id = 1; + $collection->name = 'Test Collection'; + + // Set empty relation + $collection->setRelation('targetStatistics', new EloquentCollection([])); + + /** @var TargetStatisticProcessingServiceContract|MockInterface $processingService */ + $processingService = Mockery::mock(TargetStatisticProcessingServiceContract::class); + $processingService->shouldNotReceive('processSingleTargetStatistic'); + + $job = new ProcessTargetStatisticsJob($collection); + $job->handle($processingService); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } +} \ No newline at end of file diff --git a/code/tests/Athenia/Unit/Jobs/Statistics/RecountStatisticJobTest.php b/code/tests/Athenia/Unit/Jobs/Statistics/RecountStatisticJobTest.php new file mode 100644 index 00000000..3df92946 --- /dev/null +++ b/code/tests/Athenia/Unit/Jobs/Statistics/RecountStatisticJobTest.php @@ -0,0 +1,86 @@ +id = 1; + $collection->name = 'Test Collection'; + + // Create a real Statistic model without saving + $statistic = new Statistic(); + $statistic->id = 1; + $statistic->name = 'Test Statistic'; + + // Create real TargetStatistic models without saving + $targetStatistics = new EloquentCollection([ + new TargetStatistic([ + 'id' => 1, + 'target_id' => $collection->id, + 'target_type' => 'collection', + 'statistic_id' => $statistic->id, + ]), + new TargetStatistic([ + 'id' => 2, + 'target_id' => $collection->id, + 'target_type' => 'collection', + 'statistic_id' => $statistic->id, + ]), + ]); + + // Associate the target statistics with the statistic + $statistic->setRelation('targetStatistics', $targetStatistics); + + /** @var TargetStatisticProcessingServiceContract|MockInterface $processingService */ + $processingService = Mockery::mock(TargetStatisticProcessingServiceContract::class); + + // Setup expectations for each statistic + foreach ($targetStatistics as $targetStatistic) { + $processingService->shouldReceive('processSingleTargetStatistic') + ->with($targetStatistic) + ->once(); + } + + $job = new RecountStatisticJob($statistic); + $job->handle($processingService); + } + + public function testHandleWithNoTargetStatistics(): void + { + // Create a real Statistic model without saving + $statistic = new Statistic(); + $statistic->id = 1; + $statistic->name = 'Test Statistic'; + + // Set empty relation + $statistic->setRelation('targetStatistics', new EloquentCollection([])); + + /** @var TargetStatisticProcessingServiceContract|MockInterface $processingService */ + $processingService = Mockery::mock(TargetStatisticProcessingServiceContract::class); + $processingService->shouldNotReceive('processSingleTargetStatistic'); + + $job = new RecountStatisticJob($statistic); + $job->handle($processingService); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } +} \ No newline at end of file diff --git a/code/tests/Athenia/Unit/Listeners/Statistic/StatisticCreatedListenerTest.php b/code/tests/Athenia/Unit/Listeners/Statistic/StatisticCreatedListenerTest.php new file mode 100644 index 00000000..76fa014f --- /dev/null +++ b/code/tests/Athenia/Unit/Listeners/Statistic/StatisticCreatedListenerTest.php @@ -0,0 +1,61 @@ +id = 1; + $statistic->name = 'Test Statistic'; + + $event = new StatisticCreatedEvent($statistic); + + // Create mock target statistics + $targetStatistics = new Collection([ + new TargetStatistic([ + 'id' => 1, + 'target_id' => 1, + 'target_type' => 'collection', + 'statistic_id' => $statistic->id, + ]), + ]); + + /** @var StatisticSynchronizationServiceContract|MockInterface $synchronizationService */ + $synchronizationService = Mockery::mock(StatisticSynchronizationServiceContract::class); + $synchronizationService->shouldReceive('createTargetStatisticsForStatistic') + ->with($statistic) + ->once() + ->andReturn($targetStatistics); + + /** @var Dispatcher|MockInterface $dispatcher */ + $dispatcher = Mockery::mock(Dispatcher::class); + $dispatcher->shouldReceive('dispatch') + ->with(Mockery::type(RecountStatisticJob::class)) + ->once(); + + $listener = new StatisticCreatedListener($dispatcher, $synchronizationService); + $listener->handle($event); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } +} \ No newline at end of file diff --git a/code/tests/Athenia/Unit/Listeners/Statistic/StatisticDeletedListenerTest.php b/code/tests/Athenia/Unit/Listeners/Statistic/StatisticDeletedListenerTest.php new file mode 100644 index 00000000..92664656 --- /dev/null +++ b/code/tests/Athenia/Unit/Listeners/Statistic/StatisticDeletedListenerTest.php @@ -0,0 +1,49 @@ +id = 1; + + $targetStatistics = new Collection([ + new TargetStatistic(['id' => 1]), + new TargetStatistic(['id' => 2]), + new TargetStatistic(['id' => 3]), + ]); + + $statistic->setRelation('targetStatistics', $targetStatistics); + + $event = new StatisticDeletedEvent($statistic); + + /** @var TargetStatisticRepositoryContract|MockInterface $targetStatisticRepository */ + $targetStatisticRepository = Mockery::mock(TargetStatisticRepositoryContract::class); + $targetStatisticRepository->shouldReceive('delete') + ->times(3) + ->with(Mockery::type(TargetStatistic::class)); + + $listener = new StatisticDeletedListener($targetStatisticRepository); + $listener->handle($event); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } +} \ No newline at end of file diff --git a/code/tests/Athenia/Unit/Listeners/Statistic/StatisticUpdatedListenerTest.php b/code/tests/Athenia/Unit/Listeners/Statistic/StatisticUpdatedListenerTest.php new file mode 100644 index 00000000..ad0c1e0d --- /dev/null +++ b/code/tests/Athenia/Unit/Listeners/Statistic/StatisticUpdatedListenerTest.php @@ -0,0 +1,43 @@ +id = 234; + + $event = new StatisticUpdatedEvent($statistic); + + /** @var Dispatcher|MockInterface $dispatcher */ + $dispatcher = Mockery::mock(Dispatcher::class); + $dispatcher->shouldReceive('dispatch') + ->with(Mockery::type(RecountStatisticJob::class)) + ->once(); + + $listener = new StatisticUpdatedListener($dispatcher); + $listener->handle($event); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } +} \ No newline at end of file diff --git a/code/tests/Athenia/Unit/Models/Collection/CollectionItemTest.php b/code/tests/Athenia/Unit/Models/Collection/CollectionItemTest.php index bd663f82..20652dca 100644 --- a/code/tests/Athenia/Unit/Models/Collection/CollectionItemTest.php +++ b/code/tests/Athenia/Unit/Models/Collection/CollectionItemTest.php @@ -3,7 +3,12 @@ namespace Tests\Athenia\Unit\Models\Collection; +use App\Athenia\Contracts\Models\CanBeAggregatedContract; +use App\Models\Collection\Collection; use App\Models\Collection\CollectionItem; +use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; +use Illuminate\Database\Eloquent\Relations\MorphTo; use Tests\TestCase; final class CollectionItemTest extends TestCase @@ -13,8 +18,7 @@ public function testItem(): void $model = new CollectionItem(); $relation = $model->item(); - $this->assertEquals('collection_items.item_id', $relation->getQualifiedForeignKeyName()); - $this->assertEquals('item_type', $relation->getMorphType()); + $this->assertInstanceOf(MorphTo::class, $relation); } public function testCategories(): void @@ -22,10 +26,8 @@ public function testCategories(): void $model = new CollectionItem(); $relation = $model->categories(); + $this->assertInstanceOf(BelongsToMany::class, $relation); $this->assertEquals('collection_item_categories', $relation->getTable()); - $this->assertEquals('collection_item_categories.collection_item_id', $relation->getQualifiedForeignPivotKeyName()); - $this->assertEquals('collection_item_categories.category_id', $relation->getQualifiedRelatedPivotKeyName()); - $this->assertEquals('collection_items.id', $relation->getQualifiedParentKeyName()); } public function testCollection(): void @@ -33,7 +35,7 @@ public function testCollection(): void $model = new CollectionItem(); $relation = $model->collection(); - $this->assertEquals('collections.id', $relation->getQualifiedOwnerKeyName()); - $this->assertEquals('collection_items.collection_id', $relation->getQualifiedForeignKeyName()); + $this->assertInstanceOf(BelongsTo::class, $relation); + $this->assertInstanceOf(Collection::class, $relation->getRelated()); } } \ No newline at end of file diff --git a/code/tests/Athenia/Unit/Models/Collection/CollectionTest.php b/code/tests/Athenia/Unit/Models/Collection/CollectionTest.php index 93c37f05..4cf5ccce 100644 --- a/code/tests/Athenia/Unit/Models/Collection/CollectionTest.php +++ b/code/tests/Athenia/Unit/Models/Collection/CollectionTest.php @@ -25,4 +25,15 @@ public function testOwner(): void $this->assertEquals('collections.owner_id', $relation->getQualifiedForeignKeyName()); $this->assertEquals('owner_type', $relation->getMorphType()); } + + public function testTargetStatisticsRelation(): void + { + $model = new \App\Models\Collection\Collection(); + $relation = $model->targetStatistics(); + + $this->assertInstanceOf(\Illuminate\Database\Eloquent\Relations\MorphMany::class, $relation); + $this->assertEquals('target_type', $relation->getMorphType()); + $this->assertEquals('target_id', $relation->getForeignKeyName()); + $this->assertEquals(\App\Models\Statistics\TargetStatistic::class, get_class($relation->getRelated())); + } } \ No newline at end of file diff --git a/code/tests/Athenia/Unit/Models/Statistics/StatisticFilterTest.php b/code/tests/Athenia/Unit/Models/Statistics/StatisticFilterTest.php new file mode 100644 index 00000000..1aa0e52e --- /dev/null +++ b/code/tests/Athenia/Unit/Models/Statistics/StatisticFilterTest.php @@ -0,0 +1,21 @@ +statistic(); + + $this->assertInstanceOf(BelongsTo::class, $relation); + $this->assertEquals(Statistic::class, get_class($relation->getRelated())); + } +} \ No newline at end of file diff --git a/code/tests/Athenia/Unit/Models/Statistics/StatisticTest.php b/code/tests/Athenia/Unit/Models/Statistics/StatisticTest.php new file mode 100644 index 00000000..5d2accec --- /dev/null +++ b/code/tests/Athenia/Unit/Models/Statistics/StatisticTest.php @@ -0,0 +1,31 @@ +statisticFilters(); + + $this->assertInstanceOf(HasMany::class, $relation); + $this->assertEquals(StatisticFilter::class, get_class($relation->getRelated())); + } + + public function testTargetStatisticsRelation(): void + { + $model = new Statistic(); + $relation = $model->targetStatistics(); + + $this->assertInstanceOf(HasMany::class, $relation); + $this->assertEquals(TargetStatistic::class, get_class($relation->getRelated())); + } +} \ No newline at end of file diff --git a/code/tests/Athenia/Unit/Models/Statistics/TargetStatisticTest.php b/code/tests/Athenia/Unit/Models/Statistics/TargetStatisticTest.php new file mode 100644 index 00000000..45eb45d4 --- /dev/null +++ b/code/tests/Athenia/Unit/Models/Statistics/TargetStatisticTest.php @@ -0,0 +1,34 @@ +target(); + + $this->assertEquals('target_type', $relation->getMorphType()); + $this->assertEquals('target_id', $relation->getForeignKeyName()); + } + + public function testStatisticRelationship() + { + $model = new TargetStatistic(); + $relation = $model->statistic(); + + $this->assertEquals('statistic_id', $relation->getForeignKeyName()); + $this->assertInstanceOf(Statistic::class, $relation->getRelated()); + } +} \ No newline at end of file diff --git a/code/tests/Athenia/Unit/Models/Traits/HasStatisticTargetsTest.php b/code/tests/Athenia/Unit/Models/Traits/HasStatisticTargetsTest.php new file mode 100644 index 00000000..7a04fda3 --- /dev/null +++ b/code/tests/Athenia/Unit/Models/Traits/HasStatisticTargetsTest.php @@ -0,0 +1,31 @@ +targetStatistics(); + + $this->assertInstanceOf(MorphMany::class, $relation); + $this->assertEquals('target_type', $relation->getMorphType()); + $this->assertEquals('target_id', $relation->getForeignKeyName()); + $this->assertEquals(TargetStatistic::class, get_class($relation->getRelated())); + } +} \ No newline at end of file diff --git a/code/tests/Athenia/Unit/Models/Traits/HasValidationRulesTest.php b/code/tests/Athenia/Unit/Models/Traits/HasValidationRulesTest.php new file mode 100755 index 00000000..9b49a64d --- /dev/null +++ b/code/tests/Athenia/Unit/Models/Traits/HasValidationRulesTest.php @@ -0,0 +1,85 @@ +assertEmpty($model->getValidationRules()); + } + + public function testGetSimpleRules() + { + $model = new class implements HasValidationRulesContract { + use HasValidationRules; + public function buildModelValidationRules(...$params): array + { + return [ + HasValidationRulesContract::VALIDATION_RULES_BASE => ['hi'] + ]; + } + }; + $this->assertEquals(['hi'], $model->getValidationRules()); + } + + public function testContextSetButDoesNotExist() + { + $model = new class implements HasValidationRulesContract { + use HasValidationRules; + public function buildModelValidationRules(...$params): array + { + return [ + HasValidationRulesContract::VALIDATION_RULES_BASE => ['hi'] + ]; + } + }; + $this->assertEquals(['hi'], $model->getValidationRules('notExist')); + } + + public function testContextSetButDoesNotMatch() + { + $model = new class implements HasValidationRulesContract { + use HasValidationRules; + public function buildModelValidationRules(...$params): array + { + return [ + HasValidationRulesContract::VALIDATION_RULES_BASE => ['hi' => 'there'], + 'context-here' => ['prepend-non' => ['here']] + ]; + } + }; + $this->assertEquals(['hi' => 'there'], $model->getValidationRules('context-here')); + } + + public function testContextPrepends() + { + $model = new class implements HasValidationRulesContract { + use HasValidationRules; + public function buildModelValidationRules(...$params): array + { + return [ + HasValidationRulesContract::VALIDATION_RULES_BASE => ['property_name' => ['integer']], + 'update-context' => ['prepend-required' => ['property_name']] + ]; + } + }; + $this->assertEquals(['property_name' => ['required', 'integer']], $model->getValidationRules('update-context')); + } +} diff --git a/code/tests/Athenia/Unit/Observers/AggregatedModelObserverTest.php b/code/tests/Athenia/Unit/Observers/AggregatedModelObserverTest.php new file mode 100644 index 00000000..4cef8c01 --- /dev/null +++ b/code/tests/Athenia/Unit/Observers/AggregatedModelObserverTest.php @@ -0,0 +1,103 @@ +relationTraversalService = Mockery::mock(RelationTraversalServiceContract::class); + $this->dispatcher = Mockery::mock(Dispatcher::class); + $this->observer = new AggregatedModelObserver( + $this->relationTraversalService, + $this->dispatcher + ); + } + + /** + * @dataProvider modelEventProvider + */ + public function testModelEventsDispatchJobForStatisticTarget(string $event): void + { + $collection = new Collection([ + 'id' => 1, + 'name' => 'Test Collection', + 'owner_id' => 1, + 'owner_type' => 'user', + 'is_public' => true, + ]); + + $collectionItem = new CollectionItem([ + 'id' => 1, + 'collection_id' => 1, + 'item_id' => 1, + 'item_type' => 'article', + 'order' => 1, + ]); + + $this->relationTraversalService->shouldReceive('traverseRelations') + ->with($collectionItem, 'collection') + ->andReturn(new EloquentCollection([$collection])); + + $this->dispatcher->shouldReceive('dispatch') + ->with(Mockery::type(ProcessTargetStatisticsJob::class)) + ->once(); + + $this->observer->$event($collectionItem); + } + + /** + * @dataProvider modelEventProvider + */ + public function testModelEventsDoNotDispatchJobForNonStatisticTarget(string $event): void + { + $collectionItem = new CollectionItem([ + 'id' => 1, + 'collection_id' => 1, + 'item_id' => 1, + 'item_type' => 'article', + 'order' => 1, + ]); + + $this->relationTraversalService->shouldReceive('traverseRelations') + ->with($collectionItem, 'collection') + ->andReturn(new EloquentCollection([new \stdClass()])); + + $this->dispatcher->shouldNotReceive('dispatch'); + + $this->observer->$event($collectionItem); + } + + public static function modelEventProvider(): array + { + return [ + 'created event' => ['created'], + 'updated event' => ['updated'], + 'deleted event' => ['deleted'], + ]; + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } +} \ No newline at end of file diff --git a/code/tests/Athenia/Unit/Observers/IndexableModelObserverTest.php b/code/tests/Athenia/Unit/Observers/IndexableModelObserverTest.php index d586a14b..32acfb7d 100644 --- a/code/tests/Athenia/Unit/Observers/IndexableModelObserverTest.php +++ b/code/tests/Athenia/Unit/Observers/IndexableModelObserverTest.php @@ -3,8 +3,9 @@ namespace Tests\Athenia\Unit\Observers; +use App\Athenia\Contracts\Models\CanBeIndexedContract; use App\Athenia\Contracts\Repositories\ResourceRepositoryContract; -use App\Athenia\Observer\IndexableModelObserver; +use App\Athenia\Observers\IndexableModelObserver; use App\Models\Resource; use App\Models\User\User; use Tests\CustomMockInterface; @@ -36,23 +37,21 @@ protected function setUp(): void public function testCreated(): void { - $user = new User([ + $model = new User([ + 'id' => 123, + 'name' => 'Test User', 'resource' => null, - 'name' => 'Someone', ]); - $this->resourceRepository->shouldReceive('create')->once()->with(\Mockery::on(function($data) { - - $this->assertArrayHasKey('content', $data); - $this->assertArrayHasKey('resource_id', $data); - $this->assertArrayHasKey('resource_type', $data); - - $this->assertEquals('user', $data['resource_type']); - - return true; - })); + $this->resourceRepository->shouldReceive('create') + ->with([ + 'content' => $model->getContentString(), + 'resource_id' => 123, + 'resource_type' => 'user', + ]) + ->once(); - $this->observer->created($user); + $this->observer->created($model); } public function testUpdated(): void diff --git a/code/tests/Athenia/Unit/Observers/Payment/PaymentMethodObserverTest.php b/code/tests/Athenia/Unit/Observers/Payment/PaymentMethodObserverTest.php index 29ea77f5..5ba370a5 100644 --- a/code/tests/Athenia/Unit/Observers/Payment/PaymentMethodObserverTest.php +++ b/code/tests/Athenia/Unit/Observers/Payment/PaymentMethodObserverTest.php @@ -3,7 +3,8 @@ namespace Tests\Athenia\Unit\Observers\Payment; -use App\Athenia\Observer\Payment\PaymentMethodObserver; +use App\Athenia\Events\Payment\DefaultPaymentMethodSetEvent; +use App\Athenia\Observers\Payment\PaymentMethodObserver; use App\Models\Payment\PaymentMethod; use Illuminate\Contracts\Events\Dispatcher; use Tests\CustomMockInterface; @@ -35,27 +36,23 @@ protected function setUp(): void public function testCreated(): void { - $this->observer->created(new PaymentMethod([ - 'default' => false, - ])); + $paymentMethod = new PaymentMethod([ + 'default' => true, + ]); $this->dispatcher->shouldReceive('dispatch')->once(); - $this->observer->created(new PaymentMethod([ - 'default' => true, - ])); + $this->observer->created($paymentMethod); } public function testUpdated(): void { - $this->observer->updated(new PaymentMethod([ - 'default' => false, - ])); + $paymentMethod = new PaymentMethod([ + 'default' => true, + ]); $this->dispatcher->shouldReceive('dispatch')->once(); - $this->observer->updated(new PaymentMethod([ - 'default' => true, - ])); + $this->observer->updated($paymentMethod); } } diff --git a/code/tests/Athenia/Unit/Providers/AppServiceProviderTest.php b/code/tests/Athenia/Unit/Providers/AppServiceProviderTest.php index 6054b4a4..f4d02e39 100644 --- a/code/tests/Athenia/Unit/Providers/AppServiceProviderTest.php +++ b/code/tests/Athenia/Unit/Providers/AppServiceProviderTest.php @@ -4,9 +4,11 @@ namespace Tests\Athenia\Unit\Providers; use Barryvdh\LaravelIdeHelper\IdeHelperServiceProvider; +use Illuminate\Contracts\Container\BindingResolutionException; use Illuminate\Foundation\Application; use Illuminate\Support\Str; use App\Providers\AppServiceProvider; +use PHPUnit\Framework\Attributes\DataProvider; use Tests\TestCase; /** @@ -15,10 +17,7 @@ */ final class AppServiceProviderTest extends TestCase { - /** - * @dataProvider allProviders - * @param $provide - */ + #[DataProvider('allProviders')] public function testBinds($provide): void { $this->app->make($provide); @@ -35,7 +34,9 @@ public function testProvidesAll(): void return $carry; }, []); - $this->assertEquals(0, count(array_diff(array_merge($provides, $contracts), array_intersect($provides, $contracts)))); + $misconfigured = array_values(array_diff(array_merge($provides, $contracts), array_intersect($provides, $contracts))); + + $this->assertEmpty($misconfigured, "The following services are misconfigured " . json_encode($misconfigured)); } /** diff --git a/code/tests/Athenia/Unit/Services/Relations/RelationTraversalServiceTest.php b/code/tests/Athenia/Unit/Services/Relations/RelationTraversalServiceTest.php new file mode 100644 index 00000000..585940ff --- /dev/null +++ b/code/tests/Athenia/Unit/Services/Relations/RelationTraversalServiceTest.php @@ -0,0 +1,161 @@ +service = new RelationTraversalService(); + } + + public function testTraverseRelationsWithEmptyPath() + { + $collection = new CollectionModel(); + $collection->id = 1; + + $result = $this->service->traverseRelations($collection, ''); + + $this->assertInstanceOf(Collection::class, $result); + $this->assertEquals(1, $result->count()); + $this->assertSame($collection, $result->first()); + } + + public function testTraverseRelationsWithSingleRelation() + { + $collection = new CollectionModel(); + $collection->id = 1; + + $collectionItem = new CollectionItem(); + $collectionItem->id = 2; + $collectionItem->collection_id = $collection->id; + + $collection->setRelation('items', new Collection([$collectionItem])); + + $result = $this->service->traverseRelations($collection, 'items'); + + $this->assertInstanceOf(Collection::class, $result); + $this->assertEquals(1, $result->count()); + $this->assertSame($collectionItem, $result->first()); + } + + public function testTraverseRelationsWithNestedRelations() + { + $collection = new CollectionModel(); + $collection->id = 1; + + $parentItem = new CollectionItem(); + $parentItem->id = 2; + $parentItem->collection_id = $collection->id; + + $childItem = new CollectionItem(); + $childItem->id = 3; + $childItem->collection_id = $collection->id; + + $parentItem->setRelation('children', new Collection([$childItem])); + $collection->setRelation('items', new Collection([$parentItem])); + + $result = $this->service->traverseRelations($collection, 'items.children'); + + $this->assertInstanceOf(Collection::class, $result); + $this->assertEquals(1, $result->count()); + $this->assertSame($childItem, $result->first()); + } + + public function testTraverseRelationsWithMixedRelationTypes() + { + $collection = new CollectionModel(); + $collection->id = 1; + + $collectionItem1 = new CollectionItem(); + $collectionItem1->id = 2; + $collectionItem1->collection_id = $collection->id; + + $collectionItem2 = new CollectionItem(); + $collectionItem2->id = 3; + $collectionItem2->collection_id = $collection->id; + + $collection->setRelation('items', new Collection([$collectionItem1, $collectionItem2])); + + $result = $this->service->traverseRelations($collection, 'items'); + + $this->assertInstanceOf(Collection::class, $result); + $this->assertEquals(2, $result->count()); + $this->assertSame($collectionItem1, $result->first()); + $this->assertSame($collectionItem2, $result->last()); + } + + public function testTraverseRelationsWithPreloadedRelations() + { + $collection = new CollectionModel(); + $collection->id = 1; + + $collectionItem = new CollectionItem(); + $collectionItem->id = 2; + $collectionItem->collection_id = $collection->id; + + $collection->setRelation('items', new Collection([$collectionItem])); + + $result = $this->service->traverseRelations($collection, 'items'); + + $this->assertInstanceOf(Collection::class, $result); + $this->assertEquals(1, $result->count()); + $this->assertSame($collectionItem, $result->first()); + } + + public function testTraverseRelationsWithThreeLevelNesting() + { + // Create the initial user + $user = new User(); + $user->id = 1; + + // Create an article created by the user + $article = new Article(); + $article->id = 2; + $article->created_by_id = $user->id; + + // Create an iteration of the article + $iteration = new ArticleIteration(); + $iteration->id = 3; + $iteration->article_id = $article->id; + $iteration->created_by_id = 4; // Different user created the iteration + + // Create the user who created the iteration + $iterationCreator = new User(); + $iterationCreator->id = 4; + + // Set up the relations + $user->setRelation('createdArticles', new Collection([$article])); + $article->setRelation('iterations', new Collection([$iteration])); + $iteration->setRelation('createdBy', $iterationCreator); + + $result = $this->service->traverseRelations($user, 'createdArticles.iterations.createdBy'); + + $this->assertInstanceOf(Collection::class, $result); + $this->assertEquals(1, $result->count()); + $this->assertSame($iterationCreator, $result->first()); + } +} \ No newline at end of file diff --git a/code/tests/Athenia/Unit/Services/Statistics/TargetStatisticProcessingServiceTest.php b/code/tests/Athenia/Unit/Services/Statistics/TargetStatisticProcessingServiceTest.php new file mode 100644 index 00000000..4c986211 --- /dev/null +++ b/code/tests/Athenia/Unit/Services/Statistics/TargetStatisticProcessingServiceTest.php @@ -0,0 +1,159 @@ +relationTraversalService = Mockery::mock(RelationTraversalServiceContract::class); + $this->targetStatisticRepository = Mockery::mock(TargetStatisticRepositoryContract::class); + $this->service = new TargetStatisticProcessingService( + $this->relationTraversalService, + $this->targetStatisticRepository + ); + } + + public function testProcessSingleTargetStatisticWithTotalCount() + { + $relatedModels = new BaseCollection([ + $this->createModelWithValue('test1', 10), + $this->createModelWithValue('test2', 20), + ]); + + $filter = new StatisticFilter([ + 'operator' => '>', + 'field' => 'value', + 'value' => '15', + ]); + + $statistic = new Statistic([ + 'relation' => 'test_relation', + ]); + $statistic->filters = new BaseCollection([$filter]); + + $targetStatistic = new TargetStatistic([ + 'id' => 1, + 'target_id' => 1, + 'target_type' => 'collection', + 'statistic_id' => 1, + ]); + $targetStatistic->setRelation('statistic', $statistic); + $targetStatistic->setRelation('target', new CollectionModel()); + + $this->relationTraversalService->shouldReceive('traverseRelations') + ->with($targetStatistic->target, 'test_relation') + ->andReturn($relatedModels); + + $this->targetStatisticRepository->shouldReceive('update') + ->with(Mockery::on(function ($targetStat) use ($targetStatistic) { + return $targetStat instanceof TargetStatistic; + }), Mockery::on(function ($data) { + return isset($data['result']) && $data['result']['total'] === 1; + })) + ->once(); + + $this->service->processSingleTargetStatistic($targetStatistic); + } + + public function testProcessSingleTargetStatisticWithUniqueValues() + { + $relatedModels = new BaseCollection([ + $this->createModelWithValue('collection1', 10), + $this->createModelWithValue('collection1', 20), + $this->createModelWithValue('collection2', 30), + $this->createModelWithValue('collection2', 40), + ]); + + $uniqueFilter = new StatisticFilter([ + 'operator' => 'unique', + 'field' => 'name', + ]); + + $valueFilter = new StatisticFilter([ + 'operator' => '>', + 'field' => 'value', + 'value' => '15', + ]); + + $statistic = new Statistic([ + 'relation' => 'test_relation', + ]); + $statistic->filters = new BaseCollection([$uniqueFilter, $valueFilter]); + + $targetStatistic = new TargetStatistic([ + 'id' => 1, + 'target_id' => 1, + 'target_type' => 'collection', + 'statistic_id' => 1, + ]); + $targetStatistic->setRelation('statistic', $statistic); + $targetStatistic->setRelation('target', new CollectionModel()); + + $this->relationTraversalService->shouldReceive('traverseRelations') + ->with($targetStatistic->target, 'test_relation') + ->andReturn($relatedModels); + + $expectedResult = [ + 'collection1' => 1, + 'collection2' => 2, + ]; + + $this->targetStatisticRepository->shouldReceive('update') + ->with(Mockery::type(TargetStatistic::class), Mockery::subset(['result' => $expectedResult])) + ->once(); + + $this->service->processSingleTargetStatistic($targetStatistic); + } + + private function createModelWithValue(string $name, int $value): Model + { + $model = new CollectionModel(); + $model->name = $name; + $model->value = $value; + return $model; + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } +} \ No newline at end of file diff --git a/code/tests/Athenia/Unit/Services/UserAuthenticationServiceTest.php b/code/tests/Athenia/Unit/Services/UserAuthenticationServiceTest.php new file mode 100644 index 00000000..8e19fe55 --- /dev/null +++ b/code/tests/Athenia/Unit/Services/UserAuthenticationServiceTest.php @@ -0,0 +1,63 @@ +shouldReceive('findOrFail')->once()->with(12)->andReturn($user); + $hasherMock = mock(Hasher::class); + $service = new UserAuthenticationService($hasherMock, $userRepositoryMock); + + $this->assertEquals($user, $service->retrieveById(12)); + } + + public function testRetrieveByEmailCredential() + { + $user = new User(); + + $userRepositoryMock = mock(UserRepositoryContract::class); + $userRepositoryMock->shouldReceive('findByEmail')->once()->with('guy@smiley.com')->andReturn($user); + $hasherMock = mock(Hasher::class); + $service = new UserAuthenticationService($hasherMock, $userRepositoryMock); + + $this->assertEquals($user, $service->retrieveByCredentials(['email'=>'guy@smiley.com'])); + } + + public function testRetrieveByCredentialMissingEmailUsernameFails() + { + $this->expectException(AuthenticationException::class); + $this->expectExceptionMessage('No valid identifying credential.'); + $userRepositoryMock = mock(UserRepositoryContract::class); + $hasherMock = mock(Hasher::class); + $service = new UserAuthenticationService($hasherMock, $userRepositoryMock); + + $service->retrieveByCredentials([]); + } + + public function testRetrieveByCredentialsEmptyEmailFails() + { + $this->expectException(AuthenticationException::class); + $this->expectExceptionMessage('No valid identifying credential.'); + $userRepositoryMock = mock(UserRepositoryContract::class); + $hasherMock = mock(Hasher::class); + $service = new UserAuthenticationService($hasherMock, $userRepositoryMock); + + $service->retrieveByCredentials(['email' => '']); + } +} \ No newline at end of file diff --git a/dev_exec.sh b/dev_exec.sh new file mode 100755 index 00000000..85d2fd0a --- /dev/null +++ b/dev_exec.sh @@ -0,0 +1,18 @@ +#!/bin/bash +# Script to execute a command inside the Docker container +source .env +container_id=$(docker ps --filter network=$NETWORK|grep docker-php-entry|cut -d' ' -f1) + +if [ -z "$container_id" ]; then + echo "Error: No running container found" + exit 1 +fi + +if [ $# -eq 0 ]; then + echo "Error: No command provided" + echo "Usage: $0 " + exit 1 +fi + +# Execute the command inside the container +docker exec -u 0 "$container_id" "$@" \ No newline at end of file