diff --git a/app/Http/Controllers/CommentController.php b/app/Http/Controllers/CommentController.php index b07d7ab1..ca9f1dfc 100644 --- a/app/Http/Controllers/CommentController.php +++ b/app/Http/Controllers/CommentController.php @@ -6,7 +6,7 @@ use App\Http\Resources\CommentResource; use App\Http\Resources\UserResource; use App\Services\CommentService; -use App\Services\ModelResolverService; +use App\Services\UpvoteService; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; @@ -17,8 +17,9 @@ class CommentController extends Controller { - public function __construct(protected ModelResolverService $modelResolver, + public function __construct( protected CommentService $commentService, + protected UpvoteService $upvoteService, ) {} /** @@ -42,6 +43,8 @@ public function store(StoreCommentRequest $request) 'depth' => $comment->depth, ]); + $this->upvoteService->upvote('comment', $comment->id); + DB::commit(); return response()->json([ diff --git a/app/Http/Controllers/ComputerScienceResourceController.php b/app/Http/Controllers/ComputerScienceResourceController.php index 5fb38cc7..676a192e 100644 --- a/app/Http/Controllers/ComputerScienceResourceController.php +++ b/app/Http/Controllers/ComputerScienceResourceController.php @@ -8,8 +8,10 @@ use App\Models\ComputerScienceResource; use App\Services\ComputerScienceResourceFilter; use App\Services\ComputerScienceResourceService; +use App\Services\UpvoteService; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; use Inertia\Inertia; use Throwable; @@ -18,6 +20,7 @@ class ComputerScienceResourceController extends Controller { public function __construct( protected ComputerScienceResourceService $resourceService, + protected UpvoteService $upvoteService, protected ComputerScienceResourceFilter $filterService ) {} @@ -63,7 +66,21 @@ public function store(StoreResourceRequest $request) { $validatedData = $request->validated(); try { + DB::beginTransaction(); $resource = $this->resourceService->createResource($validatedData); + + Log::info('Resource created', [ + 'resource_id' => $resource->id, + 'user_id' => Auth::id(), + 'name' => $resource->name, + 'slug' => $resource->slug, + 'platforms' => $resource->platforms, + ]); + + $this->upvoteService->upvote('resource', $resource->id); + + DB::commit(); + session()->flash('success', 'Created Resource!'); return response()->json($resource); @@ -77,10 +94,13 @@ public function store(StoreResourceRequest $request) return response()->json($e->resource); } catch (Throwable $e) { - Log::error('Error creating resource', [ - 'user_id' => Auth::id(), + DB::rollBack(); + + Log::critical('Failed to create resource', [ 'error' => $e->getMessage(), 'trace' => $e->getTraceAsString(), + 'user_id' => Auth::id(), + 'data' => $validatedData, ]); return response()->json([], 500); diff --git a/app/Http/Controllers/ResourceEditsController.php b/app/Http/Controllers/ResourceEditsController.php index f0dcbc94..b69fe3fd 100644 --- a/app/Http/Controllers/ResourceEditsController.php +++ b/app/Http/Controllers/ResourceEditsController.php @@ -6,12 +6,10 @@ use App\Models\ComputerScienceResource; use App\Models\ResourceEdits; use App\Services\ResourceEditsService; +use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; -use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; -use Illuminate\Support\Facades\Storage; use Inertia\Inertia; -use Str; use Throwable; class ResourceEditsController extends Controller @@ -39,36 +37,61 @@ public function create(string $slug) public function store(ComputerScienceResource $computerScienceResource, StoreResourceEditRequest $request) { $validatedData = $request->validated(); - $proposedChanges = $validatedData['proposed_changes'] ?? []; - - $actualChanges = $this->resourceEditsService->calculateChanges($computerScienceResource, $proposedChanges); - - // Add image path to the actual changes - if (array_key_exists('image_file', $proposedChanges)) { - $actualChanges['image_path'] = null; - if (isset($proposedChanges['image_file'])) { - $path = $proposedChanges['image_file']->store('resource-edits', 'public'); - $actualChanges['image_path'] = $path; - } - unset($actualChanges['image_file']); - } - if (empty($actualChanges)) { - Log::warning("Resource edit was submitted without any changes for resource ID: {$computerScienceResource->id}"); + try { + $resourceEdit = $this->resourceEditsService->createResourceEdit($computerScienceResource, $validatedData); + + Log::info('Resource edit created', [ + 'resource_edit_id' => $resourceEdit->id, + 'resource_id' => $computerScienceResource->id, + 'user_id' => Auth::id(), + 'edit_title' => $resourceEdit->edit_title, + ]); + + return redirect()->route('resource_edits.show', ['slug' => $resourceEdit->slug]) + ->with('success', 'Edits Created!'); + } catch (\InvalidArgumentException $e) { + Log::warning('Resource edit submitted with no changes', [ + 'resource_id' => $computerScienceResource->id, + 'user_id' => Auth::id(), + 'error' => $e->getMessage(), + ]); return redirect()->back()->with('warning', 'Cannot submit an edit with no changes made.'); + } catch (Throwable $e) { + Log::critical('Failed to create resource edit', [ + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + 'resource_id' => $computerScienceResource->id, + 'user_id' => Auth::id(), + 'data' => $validatedData, + ]); + + return redirect()->back()->withErrors(['error' => 'Failed to create resource edit. Please try again.']); } + } - $resourceEdit = ResourceEdits::create([ - 'user_id' => Auth::id(), - 'computer_science_resource_id' => $computerScienceResource->id, - 'edit_title' => $validatedData['edit_title'], - 'edit_description' => $validatedData['edit_description'], - 'proposed_changes' => $actualChanges, - ]); + public function index(Request $request) + { + try { + $data = $this->resourceEditsService->getIndexData($request); - return redirect()->route('resource_edits.show', ['slug' => $resourceEdit->slug]) - ->with('success', 'Edits Created!'); + return Inertia::render('ResourceEdits/Index', $data); + } catch (Throwable $e) { + Log::error('Error loading resource edits index', [ + 'user_id' => Auth::id(), + 'query' => $request->query(), + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + + // Return an empty page with an error flash so the UI can show a message + session()->flash('error', 'Unable to load resource edits right now.'); + + return Inertia::render('ResourceEdits/Index', [ + 'resource_edits' => ResourceEdits::query()->paginate(1), + ]); + } } public function show(string $slug) @@ -83,57 +106,10 @@ public function show(string $slug) ]); } - public function merge(ResourceEditsService $editsService, ResourceEdits $resourceEdits) + public function merge(ResourceEdits $resourceEdits) { - if (! $editsService->canMergeEdits($resourceEdits)) { - return redirect()->back()->with('warning', 'Not enough approvals'); - } - - DB::beginTransaction(); try { - $resource = ComputerScienceResource::findOrFail($resourceEdits->computer_science_resource_id); - - // Go through each property in proposed_changes, and if it exists. then set the value - $changes = $resourceEdits->proposed_changes; - $proposedFields = ['name', 'description', 'page_url', 'platforms', 'difficulties', 'pricing']; - foreach ($proposedFields as $field) { - if (array_key_exists($field, $changes)) { - $resource->$field = $changes[$field]; - } - } - - if (array_key_exists('image_path', $changes)) { - if ($resource->image_path) { - Storage::disk('public')->delete($resource->image_path); - } - $destPath = null; - if (isset($changes['image_path'])) { - // Move the new file from 'resource-edits' to 'resource' - $sourcePath = $changes['image_path']; - $fileExtension = pathinfo($sourcePath, PATHINFO_EXTENSION); - $newFileName = Str::random(40).'.'.$fileExtension; - $destPath = 'resource/'.$newFileName; - - // TODO: FIGURE OUT WHAT TO DO IN CASE OF EXCEPTION IN CODE FROM LATER STEPS - Storage::disk('public')->move($sourcePath, $destPath); - } - // Update image_path in DB - $resource->image_path = $destPath; - } - - $resource->save(); - - $proposedTagFields = ['topics_tags', 'programming_languages_tags', 'general_tags']; - foreach ($proposedTagFields as $field) { - if (array_key_exists($field, $changes)) { - $resource->$field = $changes[$field]; - } - } - - // Delete the edit since we successfully merged the changes - $resourceEdits->delete(); - - DB::commit(); + $resource = $this->resourceEditsService->mergeResourceEdit($resourceEdits); Log::info('Resource edit merged', [ 'resource_id' => $resource->id, @@ -145,12 +121,20 @@ public function merge(ResourceEditsService $editsService, ResourceEdits $resourc return redirect(route('resources.show', ['slug' => $resource->slug])) ->with('success', 'Successfully Merged Changes!'); + } catch (\LogicException $e) { + Log::warning('Insufficient approvals for resource edit merge', [ + 'resource_edit_id' => $resourceEdits->id, + 'user_id' => Auth::id(), + 'error' => $e->getMessage(), + ]); + + return redirect()->back()->with('warning', 'Not enough approvals'); } catch (Throwable $e) { - DB::rollBack(); Log::critical('Failed to merge resource edits', [ 'error' => $e->getMessage(), 'trace' => $e->getTraceAsString(), 'resource_edit_id' => $resourceEdits->id, + 'user_id' => Auth::id(), ]); return redirect()->back()->withErrors(['error' => 'Failed to merge resource edits. Please try again.']); diff --git a/app/Http/Controllers/ResourceReviewController.php b/app/Http/Controllers/ResourceReviewController.php index e19b459e..c3125cdd 100644 --- a/app/Http/Controllers/ResourceReviewController.php +++ b/app/Http/Controllers/ResourceReviewController.php @@ -5,11 +5,16 @@ use App\Http\Requests\StoreResourceReviewRequest; use App\Models\ComputerScienceResource; use App\Models\ResourceReview; +use App\Services\UpvoteService; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Log; class ResourceReviewController extends Controller { + public function __construct( + protected UpvoteService $upvoteService, + ) {} + // Store the review on the resource public function store(StoreResourceReviewRequest $request, ComputerScienceResource $computerScienceResource) { @@ -58,6 +63,8 @@ public function store(StoreResourceReviewRequest $request, ComputerScienceResour 'review_id' => $review->id, ]); + $this->upvoteService->upvote('review', $review->id); + return response()->json($review); } diff --git a/app/Http/Requests/StoreCommentRequest.php b/app/Http/Requests/StoreCommentRequest.php index 90e9e01b..f773a992 100644 --- a/app/Http/Requests/StoreCommentRequest.php +++ b/app/Http/Requests/StoreCommentRequest.php @@ -2,21 +2,12 @@ namespace App\Http\Requests; -use App\Services\ModelResolverService; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Support\Facades\Auth; use Illuminate\Validation\Rule; class StoreCommentRequest extends FormRequest { - protected $modelResolver; - - public function __construct(ModelResolverService $modelResolver) - { - parent::__construct(); - $this->modelResolver = $modelResolver; - } - /** * Determine if the user is authorized to make this request. */ diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index bbf1ad4e..22bc02b6 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,6 +2,7 @@ namespace App\Providers; +use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Support\ServiceProvider; class AppServiceProvider extends ServiceProvider @@ -22,6 +23,13 @@ public function register(): void */ public function boot(): void { - // + Relation::enforceMorphMap([ + 'resource' => \App\Models\ComputerScienceResource::class, + 'review' => \App\Models\ResourceReview::class, + 'comment' => \App\Models\Comment::class, + 'edit' => \App\Models\ResourceEdits::class, + 'user' => \App\Models\User::class, + // Add other model types here + ]); } } diff --git a/app/Providers/ModelResolverServiceProvider.php b/app/Providers/ModelResolverServiceProvider.php deleted file mode 100644 index 291d8832..00000000 --- a/app/Providers/ModelResolverServiceProvider.php +++ /dev/null @@ -1,27 +0,0 @@ -app->singleton(ModelResolverService::class, function ($app) { - return new ModelResolverService; - }); - } - - /** - * Bootstrap services. - */ - public function boot(): void - { - // - } -} diff --git a/app/Services/CommentService.php b/app/Services/CommentService.php index 016489c0..d0d59f7a 100644 --- a/app/Services/CommentService.php +++ b/app/Services/CommentService.php @@ -8,6 +8,7 @@ use App\Services\SortingManagers\GeneralVotesSortingManager; use Exception; use Illuminate\Database\Eloquent\Collection; +use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Validator; @@ -16,13 +17,6 @@ class CommentService { - protected $modelResolver; - - public function __construct(ModelResolverService $resolver) - { - $this->modelResolver = $resolver; - } - /** * Get paginated comments with custom logic. * @@ -46,7 +40,7 @@ public function getPaginatedComments(string $commentableKey, int $commentableId, 'sort_by' => ['required', 'string'], ]); - $commentableType = $this->modelResolver->getModelClass($commentableKey); + $commentableType = Relation::getMorphedModel($commentableKey); Log::debug('Getting paginated comments', [ 'commentable_type' => $commentableType, 'commentable_id' => $commentableId, @@ -57,13 +51,13 @@ public function getPaginatedComments(string $commentableKey, int $commentableId, // Get the root comments: $query = Comment::where([ - 'commentable_type' => $commentableType, + 'commentable_type' => $commentableKey, 'commentable_id' => $commentableId, 'depth' => 1, ]); // Apply sorting on the comments - $query = app(GeneralVotesSortingManager::class)->applySort($query, $sortBy, Comment::class); + $query = app(GeneralVotesSortingManager::class)->applySort($query, $sortBy); $rootComments = $query->get(); Log::debug('Root comments retrieved', [ @@ -167,17 +161,18 @@ public function createComment(array $validatedData): Comment $comment->content = $validatedData['content']; $comment->user_id = Auth::id(); - $commentableType = $this->modelResolver->getModelClass($validatedData['commentable_key']); + $commentableKey = $validatedData['commentable_key']; + $commentableModel = Relation::getMorphedModel($commentableKey); $commentableId = $validatedData['commentable_id']; // Ensure that the model exists - $model = $this->modelResolver->resolve($validatedData['commentable_key'], $commentableId); + $model = $commentableModel::find($commentableId); if (! $model) { throw new NotFoundHttpException; } // Set the commentable type - $comment->commentable_type = $commentableType; + $comment->commentable_type = $commentableKey; $comment->commentable_id = $commentableId; // Top level comment @@ -209,7 +204,7 @@ public function createComment(array $validatedData): Comment Validator::validate( [ 'commentable_id' => $commentableId, - 'commentable_type' => $commentableType, + 'commentable_type' => $commentableKey, 'depth' => $new_comment_depth, 'replies_count' => $replies_count, ], diff --git a/app/Services/ComputerScienceResourceService.php b/app/Services/ComputerScienceResourceService.php index 7ad4b5ad..22827945 100644 --- a/app/Services/ComputerScienceResourceService.php +++ b/app/Services/ComputerScienceResourceService.php @@ -12,9 +12,7 @@ use App\Utilities\UrlUtilities; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; -use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; -use Illuminate\Support\Facades\Storage; use Inertia\Inertia; use Throwable; @@ -65,64 +63,39 @@ public function createResource(array $validatedData): ComputerScienceResource throw new ResourceAlreadyCreatedException($conflictingResource); } - DB::beginTransaction(); - try { - // Store the image onto storage - $path = null; - if (array_key_exists('image_file', $validatedData) && $imageFile = $validatedData['image_file']) { - // TODO: FIGURE OUT WHAT TO DO IN CASE OF EXCEPTION IN CODE FROM LATER STEPS - $path = $imageFile->store('resource', 'public'); - } - - $resource = ComputerScienceResource::create([ - 'user_id' => Auth::id(), - 'name' => $validatedData['name'], - 'image_path' => $path, - 'description' => $validatedData['description'], - 'page_url' => $validatedData['page_url'], - 'platforms' => $validatedData['platforms'], - 'difficulties' => $validatedData['difficulties'], - 'pricing' => $validatedData['pricing'], - ]); - - // Add topics as tags - $resource->topics_tags = $validatedData['topics_tags']; - - // Add programming languages as tags (if provided) - if (isset($validatedData['programming_languages_tags'])) { - $resource->programming_languages_tags = $validatedData['programming_languages_tags']; - } - - // Add general tags (if provided) - if (isset($validatedData['general_tags'])) { - $resource->general_tags = $validatedData['general_tags']; - } - - DB::commit(); - - $this->upvoteService->upvote('resource', $resource->id); + // Store the image onto storage + $path = null; + if (array_key_exists('image_file', $validatedData) && $imageFile = $validatedData['image_file']) { + // TODO: FIGURE OUT WHAT TO DO IN CASE OF EXCEPTION IN CODE FROM LATER STEPS + $path = $imageFile->store('resource', 'public'); + } - Log::info('Resource created', [ - 'resource_id' => $resource->id, - 'user_id' => Auth::id(), - 'name' => $resource->name, - 'slug' => $resource->slug, - 'platforms' => $resource->platforms, - ]); + $resource = ComputerScienceResource::create([ + 'user_id' => Auth::id(), + 'name' => $validatedData['name'], + 'image_path' => $path, + 'description' => $validatedData['description'], + 'page_url' => $validatedData['page_url'], + 'platforms' => $validatedData['platforms'], + 'difficulties' => $validatedData['difficulties'], + 'pricing' => $validatedData['pricing'], + ]); + + // Add topics as tags + $resource->topics_tags = $validatedData['topics_tags']; + + // Add programming languages as tags (if provided) + if (isset($validatedData['programming_languages_tags'])) { + $resource->programming_languages_tags = $validatedData['programming_languages_tags']; + } - return $resource; - } catch (Throwable $e) { - DB::rollBack(); + // Add general tags (if provided) + if (isset($validatedData['general_tags'])) { + $resource->general_tags = $validatedData['general_tags']; + } - Log::critical('Failed to create resource', [ - 'error' => $e->getMessage(), - 'trace' => $e->getTraceAsString(), - 'user_id' => Auth::id(), - 'data' => $validatedData, - ]); + return $resource; - throw $e; - } } /** @@ -160,7 +133,7 @@ public function getShowResourceData(Request $request, string $slug, string $tab function () use ($computerScienceResource, $sortBy, $request) { try { $query = ResourceReview::whereBelongsTo($computerScienceResource); - $query = $this->resourceSortingManager->applySort($query, $sortBy, ResourceReview::class); + $query = $this->resourceSortingManager->applySort($query, $sortBy); return $query->with('user')->paginate(10)->appends($request->query()); } catch (Throwable $e) { diff --git a/app/Services/ModelResolverService.php b/app/Services/ModelResolverService.php deleted file mode 100644 index fd1d966b..00000000 --- a/app/Services/ModelResolverService.php +++ /dev/null @@ -1,38 +0,0 @@ - \App\Models\ComputerScienceResource::class, - 'review' => \App\Models\ResourceReview::class, - 'comment' => \App\Models\Comment::class, - 'edit' => \App\Models\ResourceEdits::class, - // Add other model types here - ]; - - /** - * Finds the model that exists for the given key and id - * - * @param $key, the colloquial name for the key - * @param $id, the id for the key - * - * returns null if no model exists, otherwise, it will return the model - */ - public function resolve($key, $id) - { - $modelClass = $this->getModelClass($key); - - if (! $modelClass) { - return null; - } - - return $modelClass::find($id); - } - - public function getModelClass($key) - { - return $this->models[$key] ?? null; - } -} diff --git a/app/Services/ResourceEditsService.php b/app/Services/ResourceEditsService.php index 97dc8c56..7283205f 100644 --- a/app/Services/ResourceEditsService.php +++ b/app/Services/ResourceEditsService.php @@ -4,11 +4,22 @@ use App\Models\ComputerScienceResource; use App\Models\ResourceEdits; +use App\Services\SortingManagers\GeneralVotesSortingManager; use App\Utilities\UrlUtilities; +use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Storage; +use Illuminate\Support\Str; +use Throwable; class ResourceEditsService { + public function __construct( + protected UpvoteService $upvoteService, + protected GeneralVotesSortingManager $sortingManager, + ) {} + /** * Determines the amount of votes needed to merge the resource edit into the * @@ -90,4 +101,135 @@ public function normalize(array $array): array return $array; } + + /** + * Create a new resource edit + * + * @throws Throwable + */ + public function createResourceEdit(ComputerScienceResource $computerScienceResource, array $validatedData): ResourceEdits + { + $proposedChanges = $validatedData['proposed_changes'] ?? []; + + $actualChanges = $this->calculateChanges($computerScienceResource, $proposedChanges); + + // Add image path to the actual changes + if (array_key_exists('image_file', $proposedChanges)) { + $actualChanges['image_path'] = null; + if (isset($proposedChanges['image_file'])) { + $path = $proposedChanges['image_file']->store('resource-edits', 'public'); + $actualChanges['image_path'] = $path; + } + unset($actualChanges['image_file']); + } + + if (empty($actualChanges)) { + throw new \InvalidArgumentException('Cannot submit an edit with no changes made.'); + } + + $resourceEdit = ResourceEdits::create([ + 'user_id' => Auth::id(), + 'computer_science_resource_id' => $computerScienceResource->id, + 'edit_title' => $validatedData['edit_title'], + 'edit_description' => $validatedData['edit_description'], + 'proposed_changes' => $actualChanges, + ]); + + $this->upvoteService->upvote('edit', $resourceEdit->id); + + return $resourceEdit; + } + + /** + * Merge resource edits into the original resource + * + * @throws Throwable + */ + public function mergeResourceEdit(ResourceEdits $resourceEdits): ComputerScienceResource + { + if (! $this->canMergeEdits($resourceEdits)) { + throw new \LogicException('Not enough approvals to merge this edit.'); + } + + DB::beginTransaction(); + try { + $resource = ComputerScienceResource::findOrFail($resourceEdits->computer_science_resource_id); + + // Get the raw proposed_changes to access image_path before it's transformed + $changes = json_decode($resourceEdits->getRawOriginal('proposed_changes'), true); + + // Go through each property in proposed_changes, and if it exists, then set the value + $proposedFields = ['name', 'description', 'page_url', 'platforms', 'difficulties', 'pricing']; + foreach ($proposedFields as $field) { + if (array_key_exists($field, $changes)) { + $resource->$field = $changes[$field]; + } + } + + if (array_key_exists('image_path', $changes)) { + if ($resource->image_path) { + Storage::disk('public')->delete($resource->image_path); + } + $destPath = null; + if (isset($changes['image_path'])) { + // Move the new file from 'resource-edits' to 'resource' + $sourcePath = $changes['image_path']; + $fileExtension = pathinfo($sourcePath, PATHINFO_EXTENSION); + $newFileName = Str::random(40).'.'.$fileExtension; + $destPath = 'resource/'.$newFileName; + + Storage::disk('public')->move($sourcePath, $destPath); + } + // Update image_path in DB + $resource->image_path = $destPath; + } + + $resource->save(); + + $proposedTagFields = ['topics_tags', 'programming_languages_tags', 'general_tags']; + foreach ($proposedTagFields as $field) { + if (array_key_exists($field, $changes)) { + $resource->$field = $changes[$field]; + } + } + + // Delete the edit since we successfully merged the changes + $resourceEdits->delete(); + + DB::commit(); + + return $resource; + } catch (Throwable $e) { + DB::rollBack(); + throw $e; + } + } + + /** + * Get data for the resource edits index page (filters, pagination) + * + * @return array{ + * resource_edits: \Illuminate\Contracts\Pagination\LengthAwarePaginator, + * sortingType: string + * } + */ + public function getIndexData(Request $request): array + { + $query = ResourceEdits::query(); + + // Apply filters and sorting through the dedicated filter service + $filters = $request->query(); + $sortBy = $filters['sort_by'] ?? 'top'; + + $query = $this->sortingManager->applySort($query, $sortBy); + + $resourceEdits = $query->with('user', 'computerScienceResource') + ->paginate(20) + ->appends($request->query()); + + return [ + 'resource_edits' => $resourceEdits, + 'sortingType' => $sortBy, + ]; + } } diff --git a/app/Services/UpvoteService.php b/app/Services/UpvoteService.php index 06aa32e6..9f166901 100644 --- a/app/Services/UpvoteService.php +++ b/app/Services/UpvoteService.php @@ -2,6 +2,7 @@ namespace App\Services; +use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; @@ -10,8 +11,6 @@ class UpvoteService { - public function __construct(protected ModelResolverService $modelResolver) {} - /** * Upvote a Model */ @@ -28,7 +27,7 @@ public function upvote($typeKey, $id) DB::beginTransaction(); try { - $model = $this->modelResolver->resolve($typeKey, $id); + $model = Relation::getMorphedModel($typeKey)::find($id); if (! $model) { Log::warning('Upvote failed: Model not found', [ @@ -86,7 +85,7 @@ public function downvote($typeKey, $id) DB::beginTransaction(); try { - $model = $this->modelResolver->resolve($typeKey, $id); + $model = Relation::getMorphedModel($typeKey)::find($id); if (! $model) { Log::warning('Downvote failed: Model not found', [ diff --git a/app/SortingStrategies/VoteSortingStrategy.php b/app/SortingStrategies/VoteSortingStrategy.php index f2ad7c7f..46c53a6b 100644 --- a/app/SortingStrategies/VoteSortingStrategy.php +++ b/app/SortingStrategies/VoteSortingStrategy.php @@ -15,12 +15,12 @@ public static function supports(string $sortBy): bool public static function apply(Builder $query, string $sortBy): Builder { $table = $query->getModel()->getTable(); - $modelClass = get_class($query->getModel()); + $morphClass = $query->getModel()->getMorphClass(); // Join on polymorphic relationship - $query->join('upvote_summaries', function ($join) use ($table, $modelClass) { + $query->join('upvote_summaries', function ($join) use ($table, $morphClass) { $join->on('upvote_summaries.upvotable_id', '=', "{$table}.id") - ->where('upvote_summaries.upvotable_type', '=', $modelClass); + ->where('upvote_summaries.upvotable_type', '=', $morphClass); })->select("{$table}.*"); switch ($sortBy) { diff --git a/app/Traits/HasComments.php b/app/Traits/HasComments.php index 38041116..49bd43b7 100644 --- a/app/Traits/HasComments.php +++ b/app/Traits/HasComments.php @@ -16,7 +16,7 @@ trait HasComments public function comments(): HasMany { return $this->hasMany(Comment::class, 'commentable_id', 'id') - ->where('commentable_type', static::class); + ->where('commentable_type', $this->getMorphClass()); } /** @@ -25,7 +25,7 @@ public function comments(): HasMany public function commentsCountRelationship(): HasOne { return $this->hasOne(CommentsCount::class, 'commentable_id', 'id') - ->where('commentable_type', static::class); + ->where('commentable_type', $this->getMorphClass()); } /** diff --git a/app/Traits/HasVotes.php b/app/Traits/HasVotes.php index 101aed57..4de3b34e 100644 --- a/app/Traits/HasVotes.php +++ b/app/Traits/HasVotes.php @@ -20,7 +20,7 @@ protected static function bootHasVotes() static::created(function ($model) { UpvoteSummary::firstOrCreate([ 'upvotable_id' => $model->id, - 'upvotable_type' => get_class($model), + 'upvotable_type' => $model->getMorphClass(), ]); }); } @@ -78,14 +78,14 @@ public function upvoteSummary(): MorphOne public function upvote($userId): array { $currentVote = $this->getVoteValue($userId); - $modelType = get_class($this); + $modelType = $this->getMorphClass(); $modelId = $this->id; if ($currentVote > 0) { UpvoteProcessed::dispatch($modelType, $modelId, $currentVote, 0); return [ - 'model' => $this->deleteVote($userId), + 'model' => $this->vote($userId, 0), // The value of the user vote (-n,0,n) 'userVote' => 0, // What the change of votes of the model after the user voted @@ -108,14 +108,14 @@ public function upvote($userId): array public function downvote($userId): array { $currentVote = $this->getVoteValue($userId); - $modelType = get_class($this); + $modelType = $this->getMorphClass(); $modelId = $this->id; if ($currentVote < 0) { UpvoteProcessed::dispatch($modelType, $modelId, $currentVote, 0); return [ - 'model' => $this->deleteVote($userId), + 'model' => $this->vote($userId, 0), 'userVote' => 0, 'changeFromVote' => 0 - $currentVote, ]; diff --git a/bootstrap/providers.php b/bootstrap/providers.php index 3137717e..9a019dc5 100644 --- a/bootstrap/providers.php +++ b/bootstrap/providers.php @@ -5,6 +5,5 @@ App\Providers\Filament\AdminPanelProvider::class, App\Providers\FortifyServiceProvider::class, App\Providers\JetstreamServiceProvider::class, - App\Providers\ModelResolverServiceProvider::class, App\Providers\SocialstreamServiceProvider::class, ]; diff --git a/database/factories/CommentFactory.php b/database/factories/CommentFactory.php index 6cc2bf2d..0f80ae3b 100644 --- a/database/factories/CommentFactory.php +++ b/database/factories/CommentFactory.php @@ -4,8 +4,8 @@ use App\Models\Comment; use App\Models\User; -use App\Services\ModelResolverService; use Illuminate\Database\Eloquent\Factories\Factory; +use Illuminate\Database\Eloquent\Relations\Relation; /** * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Comment> @@ -23,8 +23,7 @@ public function definition(): array { // Pick a random commentable type from config. $commentableKey = $this->faker->randomElement(['comment', 'resource']); - $modelResolver = app(ModelResolverService::class); - $modelClass = $modelResolver->getModelClass($commentableKey); + $modelClass = Relation::getMorphedModel($commentableKey); // Use an existing user or create one. $user = User::inRandomOrder()->first() ?? User::factory()->create(); @@ -47,7 +46,7 @@ public function definition(): array // For non-comment targets, fetch or create the commentable model. $commenting = $modelClass::inRandomOrder()->first() ?? $modelClass::factory()->create(); $commentableId = $commenting->id; - $commentableType = $modelClass; + $commentableType = $commentableKey; // For non-comment targets we always create a top-level comment. $parentCommentId = null; diff --git a/database/migrations/2025_12_25_000000_convert_morph_types_to_aliases.php b/database/migrations/2025_12_25_000000_convert_morph_types_to_aliases.php new file mode 100644 index 00000000..22ff55fe --- /dev/null +++ b/database/migrations/2025_12_25_000000_convert_morph_types_to_aliases.php @@ -0,0 +1,157 @@ + 'resource', + 'App\\Models\\ResourceReview' => 'review', + 'App\\Models\\Comment' => 'comment', + 'App\\Models\\ResourceEdits' => 'edit', + 'App\\Models\\User' => 'user', + ]; + + // Update comments table + foreach ($morphMap as $className => $alias) { + DB::table('comments') + ->where('commentable_type', $className) + ->update(['commentable_type' => $alias]); + } + + // Update comments_counts table + foreach ($morphMap as $className => $alias) { + DB::table('comments_counts') + ->where('commentable_type', $className) + ->update(['commentable_type' => $alias]); + } + + // Update upvotes table + foreach ($morphMap as $className => $alias) { + DB::table('upvotes') + ->where('upvotable_type', $className) + ->update(['upvotable_type' => $alias]); + } + + // Update upvote_summaries table + foreach ($morphMap as $className => $alias) { + DB::table('upvote_summaries') + ->where('upvotable_type', $className) + ->update(['upvotable_type' => $alias]); + } + + // Update notifications table + foreach ($morphMap as $className => $alias) { + DB::table('notifications') + ->where('notifiable_type', $className) + ->update(['notifiable_type' => $alias]); + } + + // Update personal_access_tokens table + foreach ($morphMap as $className => $alias) { + DB::table('personal_access_tokens') + ->where('tokenable_type', $className) + ->update(['tokenable_type' => $alias]); + } + + // Update activity_log table (subject and causer) + foreach ($morphMap as $className => $alias) { + DB::table('activity_log') + ->where('subject_type', $className) + ->update(['subject_type' => $alias]); + + DB::table('activity_log') + ->where('causer_type', $className) + ->update(['causer_type' => $alias]); + } + + // Update taggables table + foreach ($morphMap as $className => $alias) { + DB::table('taggables') + ->where('taggable_type', $className) + ->update(['taggable_type' => $alias]); + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + // Define the reverse mapping from morph aliases to full class names + $reverseMorphMap = [ + 'resource' => 'App\\Models\\ComputerScienceResource', + 'review' => 'App\\Models\\ResourceReview', + 'comment' => 'App\\Models\\Comment', + 'edit' => 'App\\Models\\ResourceEdits', + 'user' => 'App\\Models\\User', + ]; + + // Reverse update comments table + foreach ($reverseMorphMap as $alias => $className) { + DB::table('comments') + ->where('commentable_type', $alias) + ->update(['commentable_type' => $className]); + } + + // Reverse update comments_counts table + foreach ($reverseMorphMap as $alias => $className) { + DB::table('comments_counts') + ->where('commentable_type', $alias) + ->update(['commentable_type' => $className]); + } + + // Reverse update upvotes table + foreach ($reverseMorphMap as $alias => $className) { + DB::table('upvotes') + ->where('upvotable_type', $alias) + ->update(['upvotable_type' => $className]); + } + + // Reverse update upvote_summaries table + foreach ($reverseMorphMap as $alias => $className) { + DB::table('upvote_summaries') + ->where('upvotable_type', $alias) + ->update(['upvotable_type' => $className]); + } + + // Reverse update notifications table + foreach ($reverseMorphMap as $alias => $className) { + DB::table('notifications') + ->where('notifiable_type', $alias) + ->update(['notifiable_type' => $className]); + } + + // Reverse update personal_access_tokens table + foreach ($reverseMorphMap as $alias => $className) { + DB::table('personal_access_tokens') + ->where('tokenable_type', $alias) + ->update(['tokenable_type' => $className]); + } + + // Reverse update activity_log table (subject and causer) + foreach ($reverseMorphMap as $alias => $className) { + DB::table('activity_log') + ->where('subject_type', $alias) + ->update(['subject_type' => $className]); + + DB::table('activity_log') + ->where('causer_type', $alias) + ->update(['causer_type' => $className]); + } + + // Reverse update taggables table + foreach ($reverseMorphMap as $alias => $className) { + DB::table('taggables') + ->where('taggable_type', $alias) + ->update(['taggable_type' => $className]); + } + } +}; diff --git a/package-lock.json b/package-lock.json index b97a5938..9f476072 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "html", + "name": "ComputerScienceResources.com", "lockfileVersion": 3, "requires": true, "packages": { diff --git a/resources/js/Components/ApplicationTitleLogo.vue b/resources/js/Components/ApplicationTitleLogo.vue new file mode 100644 index 00000000..9b27e06d --- /dev/null +++ b/resources/js/Components/ApplicationTitleLogo.vue @@ -0,0 +1,3 @@ + diff --git a/resources/js/Components/FrequentlyAskedQuestion.vue b/resources/js/Components/FrequentlyAskedQuestion.vue index 3c00ca35..6aff7966 100644 --- a/resources/js/Components/FrequentlyAskedQuestion.vue +++ b/resources/js/Components/FrequentlyAskedQuestion.vue @@ -11,9 +11,9 @@ const isAnswerOpen = ref(false); @click="isAnswerOpen = !isAnswerOpen" class="w-full flex items-center justify-between p-4 text-left hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors" > -

+

-

+ + About Us
+ Rules - - Resources - + + + Browse Resources + + +
+ + + + Resource Edits + +
@@ -202,7 +213,17 @@ const { isDark, toggleDark } = useDarkMode(); :active="route().current('resources.index')" class="text-primaryDark dark:text-primary hover:text-primary dark:hover:text-primaryLight" > - Resources + + Browse Resources + + + + + Resource Edits diff --git a/resources/js/Components/Resources/FilterBar.vue b/resources/js/Components/Resources/FilterBar.vue index 4c9b67da..7ce987d6 100644 --- a/resources/js/Components/Resources/FilterBar.vue +++ b/resources/js/Components/Resources/FilterBar.vue @@ -247,112 +247,112 @@ function resetFilters() {
-
- -
- - -
- - -
- - -
+
+ +
+ + +
- -
- - -
+ +
+ + +
- -
- -
- -
-
+ +
+ + +
- -
- - +
+ +
+
+
- -
- - -
+ +
+ + +
- -
- - -
+ +
+ + +
+ + +
+ +
+
diff --git a/resources/js/Components/Resources/ResourceEdit/ResourceEditsTable.vue b/resources/js/Components/Resources/ResourceEdit/ResourceEditsTable.vue new file mode 100644 index 00000000..02d3fcc9 --- /dev/null +++ b/resources/js/Components/Resources/ResourceEdit/ResourceEditsTable.vue @@ -0,0 +1,91 @@ + + + diff --git a/resources/js/Components/Resources/ResourceTabs.vue b/resources/js/Components/Resources/ResourceTabs.vue index b2170492..45771c9a 100644 --- a/resources/js/Components/Resources/ResourceTabs.vue +++ b/resources/js/Components/Resources/ResourceTabs.vue @@ -5,7 +5,7 @@ import ResourceReviews from "@/Components/Resources/Reviews/ResourceReviews.vue" import ToggleCreateReview from "@/Components/Resources/Reviews/ToggleCreateReview.vue"; import Commentable from "@/Components/Comments/Commentable.vue"; import ResourceEdits from "@/Components/Resources/ResourceEdit/ResourceEdits.vue"; -import ResourceUpvoteSorting from "@/Components/Resources/ResourceUpvoteSorting.vue"; +import UpvoteSorting from "@/Components/Upvote/UpvoteSorting.vue"; import { getConfigData } from "@/Helpers/config"; import LoadingAnimation from "@/Components/LoadingAnimation.vue"; import ProposeEditsButton from "@/Components/Resources/ResourceEdit/ProposeEditsButton.vue"; @@ -62,7 +62,7 @@ const tabs = [ preserve-scroll preserve-state prefetch - cache-for="10s" + cache-for="2s" :href="route('resources.show', { slug: props.resource.slug, tab: tabItem.value })" :class="[ 'py-4 px-1 border-b-2 font-medium text-sm transition-all duration-200', @@ -84,10 +84,10 @@ const tabs = [
-
diff --git a/resources/js/Components/Resources/ResourceUpvoteSorting.vue b/resources/js/Components/Upvote/UpvoteSorting.vue similarity index 80% rename from resources/js/Components/Resources/ResourceUpvoteSorting.vue rename to resources/js/Components/Upvote/UpvoteSorting.vue index 6cede49a..ea3a0803 100644 --- a/resources/js/Components/Resources/ResourceUpvoteSorting.vue +++ b/resources/js/Components/Upvote/UpvoteSorting.vue @@ -4,26 +4,23 @@ import { defineProps } from 'vue'; import SortUpvotesByDropdown from "@/Components/Comments/SortUpvotesByDropdown.vue"; const props = defineProps({ - resourceSlug: { + routeName: { type: String, required: true }, + routeParams: { + type: Object, + default: () => ({}) + }, initialValue: { type: String, default: 'top', - }, - tab: { - type: String, - default: 'discussion', } }) function handleSortChange(newSortType) { // Change the sort_by parameter - const baseUrl = route('resources.show', { - slug: props.resourceSlug, - tab: props.tab, - }); + const baseUrl = route(props.routeName, props.routeParams); // Create a new URL object based on the current location const url = new URL(baseUrl, window.location.origin); @@ -34,7 +31,8 @@ function handleSortChange(newSortType) { // Visit the new URL with Inertia router.visit(url.toString(), { preserveState: true, - preserveScroll: true + preserveScroll: true, + viewTransition: true }); } diff --git a/resources/js/Pages/AboutUs.vue b/resources/js/Pages/AboutUs.vue index 0a14bd30..ca447c0a 100644 --- a/resources/js/Pages/AboutUs.vue +++ b/resources/js/Pages/AboutUs.vue @@ -88,15 +88,6 @@ import { canonicalFor, defaultOgImage, SITE_NAME } from "@/Helpers/seo"; - - - - - diff --git a/resources/js/Pages/ResourceEdits/Index.vue b/resources/js/Pages/ResourceEdits/Index.vue new file mode 100644 index 00000000..196bf15f --- /dev/null +++ b/resources/js/Pages/ResourceEdits/Index.vue @@ -0,0 +1,74 @@ + + + diff --git a/routes/web.php b/routes/web.php index e5d02115..e8eee356 100644 --- a/routes/web.php +++ b/routes/web.php @@ -80,6 +80,7 @@ // Resource Edits Route::controller(ResourceEditsController::class)->group(function () { + Route::get('/resource/edit', 'index')->name('resource_edits.index'); Route::get('/resource/edit/{slug}', 'show')->name('resource_edits.show'); }); }); diff --git a/tests/Feature/CommentsTest.php b/tests/Feature/CommentsTest.php index be01820d..82793735 100644 --- a/tests/Feature/CommentsTest.php +++ b/tests/Feature/CommentsTest.php @@ -6,7 +6,7 @@ use App\Models\ComputerScienceResource; use App\Models\UpvoteSummary; use App\Models\User; -use App\Services\ModelResolverService; +use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\Feature\Utils\TestingUtils; use Tests\RequestFactories\StoreCommentRequestFactory; @@ -37,6 +37,32 @@ public function test_can_post_top_level_comment() ]); } + /** + * Test that a comment is automatically upvoted after creation. + */ + public function test_comment_is_auto_upvoted_after_creation() + { + $user = User::factory()->create(); + $this->actingAs($user); + + $resource = ComputerScienceResource::factory()->create(); + + $commentContent = 'This is a top level comment.'; + $commentData = $this->createComment('resource', $resource->id, ['content' => $commentContent]); + + $createdComment = Comment::find($commentData['id']); + $this->assertNotNull($createdComment); + + // Assert that an upvote was created for the user + $this->assertDatabaseHas('upvotes', [ + 'user_id' => $user->id, + 'upvotable_type' => 'comment', + 'upvotable_id' => $createdComment->id, + 'value' => 1, + ]); + + } + /** * Test that invalid comment data is rejected. */ @@ -86,7 +112,7 @@ public function test_can_comment_all_commentable_types() $this->actingAs($user); foreach (config('comment.commentable_keys') as $typeKey) { - $modelClass = app(ModelResolverService::class)->getModelClass($typeKey); + $modelClass = Relation::getMorphedModel($typeKey); // Skip comments if ($modelClass === Comment::class) { @@ -99,7 +125,7 @@ public function test_can_comment_all_commentable_types() $this->assertDatabaseHas('comments', [ 'content' => $commentContent, - 'commentable_type' => $modelClass, + 'commentable_type' => $typeKey, 'commentable_id' => $commentable->id, ]); } @@ -278,7 +304,7 @@ public function test_upvote_summaries_created_for_all_comments() // Verify upvote summary was created for root comment $this->assertDatabaseHas('upvote_summaries', [ 'upvotable_id' => $rootComment['id'], - 'upvotable_type' => Comment::class, + 'upvotable_type' => 'comment', ]); // Create a reply comment @@ -287,7 +313,7 @@ public function test_upvote_summaries_created_for_all_comments() // Verify upvote summary was created for reply comment $this->assertDatabaseHas('upvote_summaries', [ 'upvotable_id' => $replyComment['id'], - 'upvotable_type' => Comment::class, + 'upvotable_type' => 'comment', ]); // Also test commenting on a comment directly @@ -296,11 +322,11 @@ public function test_upvote_summaries_created_for_all_comments() // Verify upvote summary was created for comment on comment $this->assertDatabaseHas('upvote_summaries', [ 'upvotable_id' => $commentOnComment['id'], - 'upvotable_type' => Comment::class, + 'upvotable_type' => 'comment', ]); // Verify that we have exactly 3 upvote summaries for comments - $upvoteSummariesCount = UpvoteSummary::where('upvotable_type', Comment::class)->count(); + $upvoteSummariesCount = UpvoteSummary::where('upvotable_type', 'comment')->count(); $this->assertEquals(3, $upvoteSummariesCount); } @@ -317,17 +343,18 @@ public function test_cleaned_up_when_deleted() $commentData = $this->createComment('resource', $resource->id); $commentId = $commentData['id']; - // Upvote the comment + // Upvote the comment, downvote first to prevent existing upvote + $this->downvote('comment', $commentId); $this->upvote('comment', $commentId); // Verify upvote and summary exist $this->assertDatabaseHas('upvotes', [ 'upvotable_id' => $commentId, - 'upvotable_type' => Comment::class, + 'upvotable_type' => 'comment', ]); $this->assertDatabaseHas('upvote_summaries', [ 'upvotable_id' => $commentId, - 'upvotable_type' => Comment::class, + 'upvotable_type' => 'comment', ]); // Delete the comment @@ -336,11 +363,11 @@ public function test_cleaned_up_when_deleted() // Verify upvote and summary were deleted $this->assertDatabaseMissing('upvotes', [ 'upvotable_id' => $commentId, - 'upvotable_type' => Comment::class, + 'upvotable_type' => 'comment', ]); $this->assertDatabaseMissing('upvote_summaries', [ 'upvotable_id' => $commentId, - 'upvotable_type' => Comment::class, + 'upvotable_type' => 'comment', ]); } } diff --git a/tests/Feature/ComputerScienceResourceTest.php b/tests/Feature/ComputerScienceResourceTest.php index 49ca53f1..22231c65 100644 --- a/tests/Feature/ComputerScienceResourceTest.php +++ b/tests/Feature/ComputerScienceResourceTest.php @@ -43,6 +43,30 @@ public function test_can_post_resource() $this->assertNotNull($createdResource); } + public function test_resource_is_auto_upvoted_after_creation() + { + $this->actingAs($this->user); + + $formData = StoreResourceRequestFactory::new()->create(); + + $response = $this->postJson(route('resources.store'), $formData); + + $response->assertStatus(200); + + // Get the created resource + $createdResource = ComputerScienceResource::where('name', $formData['name'])->first(); + $this->assertNotNull($createdResource); + + // Assert that an upvote was created for the user + $this->assertDatabaseHas('upvotes', [ + 'user_id' => $this->user->id, + 'upvotable_type' => 'resource', + 'upvotable_id' => $createdResource->id, + 'value' => 1, + ]); + + } + public function test_can_post_resource_with_image() { Storage::fake('public'); @@ -200,11 +224,11 @@ public function test_cleans_up_when_deleted() // Assert resource and comment are deleted $this->assertDatabaseMissing('computer_science_resources', ['id' => $resource->id]); $this->assertDatabaseMissing('comments', ['id' => $commentId]); - $this->assertDatabaseMissing('comments_counts', ['commentable_id' => $resource->id, 'commentable_type' => ComputerScienceResource::class]); + $this->assertDatabaseMissing('comments_counts', ['commentable_id' => $resource->id, 'commentable_type' => 'resource']); // Assert votes and voteSummaries are gone - $this->assertDatabaseMissing('upvotes', ['upvotable_id' => $resource->id, 'upvotable_type' => ComputerScienceResource::class]); - $this->assertDatabaseMissing('upvote_summaries', ['upvotable_id' => $resource->id, 'upvotable_type' => ComputerScienceResource::class]); + $this->assertDatabaseMissing('upvotes', ['upvotable_id' => $resource->id, 'upvotable_type' => 'resource']); + $this->assertDatabaseMissing('upvote_summaries', ['upvotable_id' => $resource->id, 'upvotable_type' => 'resource']); // Assert Reviews are removed $this->assertDatabaseMissing('resource_reviews', ['id' => $resourceReview->id]); diff --git a/tests/Feature/ResourceEditsControllerTest.php b/tests/Feature/ResourceEditsControllerTest.php new file mode 100644 index 00000000..e44eba13 --- /dev/null +++ b/tests/Feature/ResourceEditsControllerTest.php @@ -0,0 +1,93 @@ +user = User::factory()->create(); + } + + /** + * Test that the resource edits index page renders successfully + */ + public function test_index_page_renders(): void + { + $response = $this->get(route('resource_edits.index')); + + $response->assertStatus(200); + $response->assertInertia(fn ($page) => $page + ->component('ResourceEdits/Index') + ->has('resource_edits') + ->has('sortingType') + ); + } + + /** + * Test that the index page displays resource edits + */ + public function test_index_page_displays_resource_edits(): void + { + $resource = ComputerScienceResource::factory()->create(); + + ResourceEdits::factory()->count(3)->create([ + 'computer_science_resource_id' => $resource->id, + ]); + + $response = $this->get(route('resource_edits.index')); + + $response->assertStatus(200); + $response->assertInertia(fn ($page) => $page + ->has('resource_edits.data', 3) + ); + } + + /** + * Test that the create page renders successfully + */ + public function test_create_page_renders(): void + { + $this->actingAs($this->user); + + $resource = ComputerScienceResource::factory()->create(); + + $response = $this->get(route('resource_edits.create', ['slug' => $resource->slug])); + + $response->assertStatus(200); + $response->assertInertia(fn ($page) => $page + ->component('ResourceEdits/Create') + ->has('resource') + ); + } + + /** + * Test that the show page renders successfully + */ + public function test_show_page_renders(): void + { + $resource = ComputerScienceResource::factory()->create(); + $edit = ResourceEdits::factory()->create([ + 'computer_science_resource_id' => $resource->id, + ]); + + $response = $this->get(route('resource_edits.show', ['slug' => $edit->slug])); + + $response->assertStatus(200); + $response->assertInertia(fn ($page) => $page + ->component('ResourceEdits/Show') + ->has('editedResource') + ); + } +} diff --git a/tests/Feature/ResourceEditsTest.php b/tests/Feature/ResourceEditsTest.php index 34eafc3c..a232ab08 100644 --- a/tests/Feature/ResourceEditsTest.php +++ b/tests/Feature/ResourceEditsTest.php @@ -139,6 +139,41 @@ public function test_can_post_valid_resource_edit(): void ]); } + /** + * Test that an edit is automatically upvoted after creation. + */ + public function test_edit_is_auto_upvoted_after_creation(): void + { + $this->actingAs($this->user); + + // Create the original resource. + $resource = ComputerScienceResource::factory()->create(); + + // Create valid edit payload and change at least one attribute. + $editData = StoreResourceEditRequestFactory::new()->create(); + $editData['proposed_changes']['name'] = $resource->name.' Updated'; + + $response = $this->post(route('resource_edits.store', $resource), $editData); + + // Expect redirection to the edit show page with a success message. + $response->assertRedirect(); + + $createdEdit = ResourceEdits::where([ + 'computer_science_resource_id' => $resource->id, + 'edit_title' => $editData['edit_title'], + ])->first(); + + $this->assertNotNull($createdEdit); + + // Assert that an upvote was created for the user + $this->assertDatabaseHas('upvotes', [ + 'user_id' => $this->user->id, + 'upvotable_type' => 'edit', + 'upvotable_id' => $createdEdit->id, + 'value' => 1, + ]); + } + /** * Test that merging an edit updates the original resource. * We run multiple merges to simulate multiple edit merges. @@ -237,10 +272,10 @@ public function test_merge_edits_deletes_all_previous_relationships(): void $this->approveResourceEdit($edit); // Assert None of found for the following: - $this->assertEmpty(Upvote::where('upvotable_id', $edit->id)->where('upvotable_type', ResourceEdits::class)->get()); - $this->assertEmpty(UpvoteSummary::where('upvotable_id', $edit->id)->where('upvotable_type', ResourceEdits::class)->get()); + $this->assertEmpty(Upvote::where('upvotable_id', $edit->id)->where('upvotable_type', 'edit')->get()); + $this->assertEmpty(UpvoteSummary::where('upvotable_id', $edit->id)->where('upvotable_type', 'edit')->get()); - $this->assertEmpty(Comment::where('commentable_id', $edit->id)->where('commentable_type', ResourceEdits::class)->get()); + $this->assertEmpty(Comment::where('commentable_id', $edit->id)->where('commentable_type', 'edit')->get()); // Check that the resource edit still edits (soft deleted) $this->assertNotEmpty(ResourceEdits::withTrashed()->find($edit->id)); diff --git a/tests/Feature/ResourceReviewsTest.php b/tests/Feature/ResourceReviewsTest.php index e61a6deb..94c7934b 100644 --- a/tests/Feature/ResourceReviewsTest.php +++ b/tests/Feature/ResourceReviewsTest.php @@ -72,6 +72,32 @@ public function test_resource_review_can_be_posted(): void ]); } + public function test_review_is_auto_upvoted_after_creation(): void + { + $user = User::factory()->create(); + $resource = ComputerScienceResource::factory()->create(); + + $data = StoreResourceReviewRequestFactory::new()->create(); + + $this->actingAs($user) + ->post(route('reviews.store', $resource), $data); + + $createdReview = \App\Models\ResourceReview::where([ + 'computer_science_resource_id' => $resource->id, + 'title' => $data['title'], + ])->first(); + + $this->assertNotNull($createdReview); + + // Assert that an upvote was created for the user + $this->assertDatabaseHas('upvotes', [ + 'user_id' => $user->id, + 'upvotable_type' => 'review', + 'upvotable_id' => $createdReview->id, + 'value' => 1, + ]); + } + public function test_resource_review_cannot_be_posted_twice(): void { $user = User::factory()->create(); diff --git a/tests/Feature/SortingStrategies/VoteSortingStrategyTest.php b/tests/Feature/SortingStrategies/VoteSortingStrategyTest.php index 2cc06575..3c95553e 100644 --- a/tests/Feature/SortingStrategies/VoteSortingStrategyTest.php +++ b/tests/Feature/SortingStrategies/VoteSortingStrategyTest.php @@ -27,17 +27,17 @@ public function test_it_sorts_resource_reviews_by_top_votes() $reviews = ResourceReview::factory()->count(3)->create(); // 10, 2, -1 - $summary1 = UpvoteSummary::where('upvotable_id', $reviews[0]->id)->where('upvotable_type', ResourceReview::class)->first(); + $summary1 = UpvoteSummary::where('upvotable_id', $reviews[0]->id)->where('upvotable_type', 'review')->first(); $summary1->upvotes = 10; $summary1->downvotes = 0; $summary1->save(); - $summary2 = UpvoteSummary::where('upvotable_id', $reviews[1]->id)->where('upvotable_type', ResourceReview::class)->first(); + $summary2 = UpvoteSummary::where('upvotable_id', $reviews[1]->id)->where('upvotable_type', 'review')->first(); $summary2->upvotes = 7; $summary2->downvotes = 8; // 7 - 8 = -1 $summary2->save(); - $summary3 = UpvoteSummary::where('upvotable_id', $reviews[2]->id)->where('upvotable_type', ResourceReview::class)->first(); + $summary3 = UpvoteSummary::where('upvotable_id', $reviews[2]->id)->where('upvotable_type', 'review')->first(); $summary3->upvotes = 6; $summary3->downvotes = 4; // 6 - 4 = 2 $summary3->save(); @@ -61,17 +61,17 @@ public function test_it_sorts_resource_reviews_by_bottom_votes() $reviews = ResourceReview::factory()->count(3)->create(); // Scores: -5, 0, 5 - $summary1 = UpvoteSummary::where('upvotable_id', $reviews[0]->id)->where('upvotable_type', ResourceReview::class)->first(); + $summary1 = UpvoteSummary::where('upvotable_id', $reviews[0]->id)->where('upvotable_type', 'review')->first(); $summary1->upvotes = 0; $summary1->downvotes = 5; $summary1->save(); - $summary2 = UpvoteSummary::where('upvotable_id', $reviews[1]->id)->where('upvotable_type', ResourceReview::class)->first(); + $summary2 = UpvoteSummary::where('upvotable_id', $reviews[1]->id)->where('upvotable_type', 'review')->first(); $summary2->upvotes = 3; $summary2->downvotes = 3; $summary2->save(); - $summary3 = UpvoteSummary::where('upvotable_id', $reviews[2]->id)->where('upvotable_type', ResourceReview::class)->first(); + $summary3 = UpvoteSummary::where('upvotable_id', $reviews[2]->id)->where('upvotable_type', 'review')->first(); $summary3->upvotes = 10; $summary3->downvotes = 5; $summary3->save(); @@ -98,17 +98,17 @@ public function test_it_sorts_resource_reviews_by_controversial() // For (up, down): (5, 5) -> 10 - 0 = 10 // (6, 4) -> 10 - 2 = 8 // (10,0) -> 10 - 10 = 0 - $summary1 = UpvoteSummary::where('upvotable_id', $reviews[0]->id)->where('upvotable_type', ResourceReview::class)->first(); + $summary1 = UpvoteSummary::where('upvotable_id', $reviews[0]->id)->where('upvotable_type', 'review')->first(); $summary1->upvotes = 5; $summary1->downvotes = 5; $summary1->save(); - $summary2 = UpvoteSummary::where('upvotable_id', $reviews[1]->id)->where('upvotable_type', ResourceReview::class)->first(); + $summary2 = UpvoteSummary::where('upvotable_id', $reviews[1]->id)->where('upvotable_type', 'review')->first(); $summary2->upvotes = 6; $summary2->downvotes = 4; $summary2->save(); - $summary3 = UpvoteSummary::where('upvotable_id', $reviews[2]->id)->where('upvotable_type', ResourceReview::class)->first(); + $summary3 = UpvoteSummary::where('upvotable_id', $reviews[2]->id)->where('upvotable_type', 'review')->first(); $summary3->upvotes = 10; $summary3->downvotes = 0; $summary3->save(); @@ -132,17 +132,17 @@ public function test_it_sorts_resource_reviews_by_total_votes() $reviews = ResourceReview::factory()->count(3)->create(); // Total votes: 5, 10, 15 - $summary1 = UpvoteSummary::where('upvotable_id', $reviews[0]->id)->where('upvotable_type', ResourceReview::class)->first(); + $summary1 = UpvoteSummary::where('upvotable_id', $reviews[0]->id)->where('upvotable_type', 'review')->first(); $summary1->upvotes = 2; $summary1->downvotes = 3; $summary1->save(); - $summary2 = UpvoteSummary::where('upvotable_id', $reviews[1]->id)->where('upvotable_type', ResourceReview::class)->first(); + $summary2 = UpvoteSummary::where('upvotable_id', $reviews[1]->id)->where('upvotable_type', 'review')->first(); $summary2->upvotes = 5; $summary2->downvotes = 5; $summary2->save(); - $summary3 = UpvoteSummary::where('upvotable_id', $reviews[2]->id)->where('upvotable_type', ResourceReview::class)->first(); + $summary3 = UpvoteSummary::where('upvotable_id', $reviews[2]->id)->where('upvotable_type', 'review')->first(); $summary3->upvotes = 10; $summary3->downvotes = 5; $summary3->save(); @@ -167,12 +167,12 @@ public function test_it_sorts_resource_reviews_by_hot() // review2: created now, score 10 $review2 = ResourceReview::factory()->create(['created_at' => $now]); - $summary1 = UpvoteSummary::where('upvotable_id', $review1->id)->where('upvotable_type', ResourceReview::class)->first(); + $summary1 = UpvoteSummary::where('upvotable_id', $review1->id)->where('upvotable_type', 'review')->first(); $summary1->upvotes = 100; $summary1->downvotes = 0; $summary1->save(); - $summary2 = UpvoteSummary::where('upvotable_id', $review2->id)->where('upvotable_type', ResourceReview::class)->first(); + $summary2 = UpvoteSummary::where('upvotable_id', $review2->id)->where('upvotable_type', 'review')->first(); $summary2->upvotes = 10; $summary2->downvotes = 0; $summary2->save(); diff --git a/tests/Feature/TagSearchTest.php b/tests/Feature/TagSearchTest.php index b9b68617..01261547 100644 --- a/tests/Feature/TagSearchTest.php +++ b/tests/Feature/TagSearchTest.php @@ -141,9 +141,9 @@ public function test_merging_edit_correctly_updates_tag_frequency() $edit = ResourceEdits::latest()->first(); // Mock approval - $this->instance(ResourceEditsService::class, Mockery::mock(ResourceEditsService::class, function ($mock) { - $mock->shouldReceive('canMergeEdits')->andReturnTrue(); - })); + $service = Mockery::mock(ResourceEditsService::class)->makePartial(); + $service->shouldReceive('canMergeEdits')->andReturnTrue(); + $this->instance(ResourceEditsService::class, $service); // Merge the edit $mergeResponse = $this->post(route('resource_edits.merge', ['resourceEdits' => $edit->id])); diff --git a/tests/Feature/UpvoteTest.php b/tests/Feature/UpvoteTest.php index a3206844..8c238ee4 100644 --- a/tests/Feature/UpvoteTest.php +++ b/tests/Feature/UpvoteTest.php @@ -4,7 +4,7 @@ use App\Models\ComputerScienceResource; use App\Models\User; -use App\Services\ModelResolverService; +use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\Feature\Utils\TestingUtils; use Tests\TestCase; @@ -48,7 +48,7 @@ public function test_can_upvote_all_upvotable_types() foreach (config('upvotes.upvotable_keys') as $typeKey) { // Get the Model service with app - $modelClass = app(ModelResolverService::class)->getModelClass($typeKey); // Resolve the model class. + $modelClass = Relation::getMorphedModel($typeKey); // Resolve the model class. $model = $modelClass::factory()->create(); @@ -56,7 +56,7 @@ public function test_can_upvote_all_upvotable_types() $this->assertDatabaseHas('upvotes', [ 'user_id' => $user->id, - 'upvotable_type' => $modelClass, + 'upvotable_type' => $typeKey, 'upvotable_id' => $model->id, 'value' => 1, ]); diff --git a/tests/Feature/Utils/TestingUtils.php b/tests/Feature/Utils/TestingUtils.php index 3932a040..c9a163f2 100644 --- a/tests/Feature/Utils/TestingUtils.php +++ b/tests/Feature/Utils/TestingUtils.php @@ -8,7 +8,6 @@ use App\Models\User; use App\Services\ResourceEditsService; use Mockery; -use Mockery\MockInterface; use Tests\RequestFactories\StoreCommentRequestFactory; use Tests\RequestFactories\StoreResourceEditRequestFactory; use Tests\RequestFactories\StoreResourceRequestFactory; @@ -68,13 +67,11 @@ public function createResourceEdit($resourceId, $changes = []): ResourceEdits public function approveResourceEdit(ResourceEdits $edit) { - // Stub the ResourceEditsService to always allow merging - $this->instance( - ResourceEditsService::class, - Mockery::mock(ResourceEditsService::class, function (MockInterface $mock) { - $mock->shouldReceive('canMergeEdits')->andReturnTrue(); - }) - ); + // Create a partial mock that only mocks canMergeEdits + $service = Mockery::mock(ResourceEditsService::class)->makePartial(); + $service->shouldReceive('canMergeEdits')->andReturnTrue(); + + $this->instance(ResourceEditsService::class, $service); // Merge the edit $mergeResponse = $this->post(route('resource_edits.merge', ['resourceEdits' => $edit->id]));