diff --git a/api-docs/.openapi-generator/FILES b/api-docs/.openapi-generator/FILES
index 9f0d0be5..d7a5f550 100644
--- a/api-docs/.openapi-generator/FILES
+++ b/api-docs/.openapi-generator/FILES
@@ -12,6 +12,7 @@ Models/AnswerDetails.md
Models/AuthToken.md
Models/CodeExecution.md
Models/DifficultyLevel.md
+Models/DifficultyLevelRequestDto.md
Models/Error.md
Models/ErrorResponseObject.md
Models/ExecutionResult.md
@@ -21,6 +22,7 @@ Models/NewQuestion.md
Models/NewTag.md
Models/Profile.md
Models/QuestionDetails.md
+Models/QuestionRateResponseDto.md
Models/QuestionSummary.md
Models/SuccessResponseObject.md
Models/SuccessResponseObject_data.md
@@ -39,10 +41,12 @@ Models/createAnswer_201_response.md
Models/createQuestion_201_response.md
Models/createTag_201_response.md
Models/executeCode_200_response.md
+Models/getBookmarkedQuestions_200_response.md
Models/getQuestionAnswers_200_response.md
Models/getQuestionAnswers_200_response_allOf_data.md
Models/getUserFollowers_200_response.md
Models/getUserProfile_200_response.md
+Models/rateQuestion_200_response.md
Models/resetPassword_request.md
Models/searchQuestions_200_response.md
Models/searchQuestions_200_response_allOf_data.md
diff --git a/api-docs/Apis/QuestionsApi.md b/api-docs/Apis/QuestionsApi.md
index 20fcf6a0..d25b5ebf 100644
--- a/api-docs/Apis/QuestionsApi.md
+++ b/api-docs/Apis/QuestionsApi.md
@@ -4,12 +4,14 @@ All URIs are relative to *http://localhost:5173/api/v1*
| Method | HTTP request | Description |
|------------- | ------------- | -------------|
-| [**bookmarkQuestion**](QuestionsApi.md#bookmarkQuestion) | **POST** /questions/{questionId}/bookmark | Bookmark a question |
+| [**bookmarkQuestion**](QuestionsApi.md#bookmarkQuestion) | **POST** /questions/{questionId}/bookmarks | Bookmark a question |
| [**createQuestion**](QuestionsApi.md#createQuestion) | **POST** /questions | Create a new question |
| [**deleteQuestion**](QuestionsApi.md#deleteQuestion) | **DELETE** /questions/{questionId} | Delete a question |
| [**downvoteQuestion**](QuestionsApi.md#downvoteQuestion) | **POST** /questions/{questionId}/downvote | Downvote a question |
+| [**getBookmarkedQuestions**](QuestionsApi.md#getBookmarkedQuestions) | **GET** /questions/bookmarked | Get bookmarked questions |
| [**getQuestionDetails**](QuestionsApi.md#getQuestionDetails) | **GET** /questions/{questionId} | Get question details |
-| [**removeQuestionBookmark**](QuestionsApi.md#removeQuestionBookmark) | **DELETE** /questions/{questionId}/bookmark | Remove bookmark from a question |
+| [**rateQuestion**](QuestionsApi.md#rateQuestion) | **POST** /questions/{id}/vote-difficulty | Rate a question's level of difficulty. |
+| [**removeQuestionBookmark**](QuestionsApi.md#removeQuestionBookmark) | **DELETE** /questions/{questionId}/bookmarks | Remove bookmark from a question |
| [**updateQuestion**](QuestionsApi.md#updateQuestion) | **PUT** /questions/{questionId} | Update a question |
| [**upvoteQuestion**](QuestionsApi.md#upvoteQuestion) | **POST** /questions/{questionId}/upvote | Upvote a question |
@@ -114,6 +116,28 @@ null (empty response body)
- **Content-Type**: Not defined
- **Accept**: application/json
+
+# **getBookmarkedQuestions**
+> getBookmarkedQuestions_200_response getBookmarkedQuestions()
+
+Get bookmarked questions
+
+### Parameters
+This endpoint does not need any parameter.
+
+### Return type
+
+[**getBookmarkedQuestions_200_response**](../Models/getBookmarkedQuestions_200_response.md)
+
+### Authorization
+
+[bearerAuth](../README.md#bearerAuth)
+
+### HTTP request headers
+
+- **Content-Type**: Not defined
+- **Accept**: application/json
+
# **getQuestionDetails**
> createQuestion_201_response getQuestionDetails(questionId)
@@ -139,6 +163,32 @@ No authorization required
- **Content-Type**: Not defined
- **Accept**: application/json
+
+# **rateQuestion**
+> rateQuestion_200_response rateQuestion(id, DifficultyLevelRequestDto)
+
+Rate a question's level of difficulty.
+
+### Parameters
+
+|Name | Type | Description | Notes |
+|------------- | ------------- | ------------- | -------------|
+| **id** | **Long**| | [default to null] |
+| **DifficultyLevelRequestDto** | [**DifficultyLevelRequestDto**](../Models/DifficultyLevelRequestDto.md)| | |
+
+### Return type
+
+[**rateQuestion_200_response**](../Models/rateQuestion_200_response.md)
+
+### Authorization
+
+No authorization required
+
+### HTTP request headers
+
+- **Content-Type**: application/json
+- **Accept**: application/json
+
# **removeQuestionBookmark**
> removeQuestionBookmark(questionId)
diff --git a/api-docs/Apis/SearchApi.md b/api-docs/Apis/SearchApi.md
index 9901d6a4..7462cb0d 100644
--- a/api-docs/Apis/SearchApi.md
+++ b/api-docs/Apis/SearchApi.md
@@ -11,7 +11,7 @@ All URIs are relative to *http://localhost:5173/api/v1*
# **searchQuestions**
-> searchQuestions_200_response searchQuestions(q, tags, difficulty, page, pageSize)
+> searchQuestions_200_response searchQuestions(q, tags, difficulty, page, pageSize, sortBy)
Search questions
@@ -24,6 +24,7 @@ Search questions
| **difficulty** | [**DifficultyLevel**](../Models/.md)| Filter by difficulty level | [optional] [default to null] [enum: EASY, MEDIUM, HARD] |
| **page** | **Integer**| Page number | [optional] [default to 1] |
| **pageSize** | **Integer**| Number of items per page | [optional] [default to 20] |
+| **sortBy** | **String**| Sorting type | [optional] [default to recommended] |
### Return type
diff --git a/api-docs/Apis/TagsApi.md b/api-docs/Apis/TagsApi.md
index 32db1fc6..d04931d6 100644
--- a/api-docs/Apis/TagsApi.md
+++ b/api-docs/Apis/TagsApi.md
@@ -45,7 +45,7 @@ Follow a tag
|Name | Type | Description | Notes |
|------------- | ------------- | ------------- | -------------|
-| **tagId** | **String**| | [default to null] |
+| **tagId** | **Integer**| | [default to null] |
### Return type
@@ -70,7 +70,7 @@ Get tag details
|Name | Type | Description | Notes |
|------------- | ------------- | ------------- | -------------|
-| **tagId** | **String**| | [default to null] |
+| **tagId** | **Integer**| | [default to null] |
### Return type
@@ -95,7 +95,7 @@ Unfollow a tag
|Name | Type | Description | Notes |
|------------- | ------------- | ------------- | -------------|
-| **tagId** | **String**| | [default to null] |
+| **tagId** | **Integer**| | [default to null] |
### Return type
diff --git a/api-docs/Models/DifficultyLevelRequestDto.md b/api-docs/Models/DifficultyLevelRequestDto.md
new file mode 100644
index 00000000..86f22e20
--- /dev/null
+++ b/api-docs/Models/DifficultyLevelRequestDto.md
@@ -0,0 +1,9 @@
+# DifficultyLevelRequestDto
+## Properties
+
+| Name | Type | Description | Notes |
+|------------ | ------------- | ------------- | -------------|
+| **difficulty** | **String** | | [optional] [default to null] |
+
+[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
+
diff --git a/api-docs/Models/NewQuestion.md b/api-docs/Models/NewQuestion.md
index 31318eaf..e26447f4 100644
--- a/api-docs/Models/NewQuestion.md
+++ b/api-docs/Models/NewQuestion.md
@@ -6,7 +6,7 @@
| **title** | **String** | | [default to null] |
| **content** | **String** | | [default to null] |
| **tagIds** | **List** | | [optional] [default to null] |
-| **difficultyLevel** | [**DifficultyLevel**](DifficultyLevel.md) | | [default to null] |
+| **difficulty** | [**DifficultyLevel**](DifficultyLevel.md) | | [default to null] |
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
diff --git a/api-docs/Models/QuestionDetails.md b/api-docs/Models/QuestionDetails.md
index 522a8136..d60ce415 100644
--- a/api-docs/Models/QuestionDetails.md
+++ b/api-docs/Models/QuestionDetails.md
@@ -12,10 +12,15 @@
| **tags** | [**List**](TagSummary.md) | | [default to null] |
| **likeCount** | **Integer** | | [default to null] |
| **dislikeCount** | **Integer** | | [default to null] |
+| **difficulty** | [**DifficultyLevel**](DifficultyLevel.md) | | [default to null] |
| **commentCount** | **Integer** | | [default to null] |
| **viewCount** | **Integer** | | [optional] [default to null] |
-| **bookmarked** | **Boolean** | | [optional] [default to null] |
-| **selfVoted** | **Integer** | | [optional] [default to null] |
+| **bookmarked** | **Boolean** | | [default to null] |
+| **selfVoted** | **Integer** | | [default to null] |
+| **selfDifficultyVote** | [**DifficultyLevel**](DifficultyLevel.md) | | [default to null] |
+| **easyCount** | **Integer** | | [default to null] |
+| **mediumCount** | **Integer** | | [default to null] |
+| **hardCount** | **Integer** | | [default to null] |
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
diff --git a/api-docs/Models/QuestionRateResponseDto.md b/api-docs/Models/QuestionRateResponseDto.md
new file mode 100644
index 00000000..c4df3acb
--- /dev/null
+++ b/api-docs/Models/QuestionRateResponseDto.md
@@ -0,0 +1,13 @@
+# QuestionRateResponseDto
+## Properties
+
+| Name | Type | Description | Notes |
+|------------ | ------------- | ------------- | -------------|
+| **questionId** | **Long** | | [optional] [default to null] |
+| **easyCount** | **Long** | | [optional] [default to null] |
+| **mediumCount** | **Long** | | [optional] [default to null] |
+| **hardCount** | **Long** | | [optional] [default to null] |
+| **totalCount** | **Long** | | [optional] [default to null] |
+
+[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
+
diff --git a/api-docs/Models/QuestionSummary.md b/api-docs/Models/QuestionSummary.md
index e235f557..705c3b0f 100644
--- a/api-docs/Models/QuestionSummary.md
+++ b/api-docs/Models/QuestionSummary.md
@@ -5,13 +5,14 @@
|------------ | ------------- | ------------- | -------------|
| **id** | **Integer** | | [default to null] |
| **title** | **String** | | [default to null] |
-| **questionBody** | **String** | | [optional] [default to null] |
+| **content** | **String** | | [optional] [default to null] |
| **author** | [**UserSummary**](UserSummary.md) | | [optional] [default to null] |
| **createdAt** | **Date** | | [default to null] |
-| **difficultyLevel** | [**DifficultyLevel**](DifficultyLevel.md) | | [default to null] |
+| **difficulty** | [**DifficultyLevel**](DifficultyLevel.md) | | [default to null] |
| **tags** | [**List**](TagSummary.md) | | [default to null] |
-| **likeCount** | **Integer** | | [default to null] |
-| **commentCount** | **Integer** | | [default to null] |
+| **upvoteCount** | **Integer** | | [default to null] |
+| **downvoteCount** | **Integer** | | [default to null] |
+| **answerCount** | **Integer** | | [default to null] |
| **viewCount** | **Integer** | | [optional] [default to null] |
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
diff --git a/api-docs/Models/TagDetails.md b/api-docs/Models/TagDetails.md
index 071dfa9f..7f98e067 100644
--- a/api-docs/Models/TagDetails.md
+++ b/api-docs/Models/TagDetails.md
@@ -3,7 +3,7 @@
| Name | Type | Description | Notes |
|------------ | ------------- | ------------- | -------------|
-| **tagId** | **String** | | [default to null] |
+| **tagId** | **Integer** | | [default to null] |
| **name** | **String** | | [default to null] |
| **tagType** | [**TagType**](TagType.md) | | [optional] [default to null] |
| **description** | **String** | | [default to null] |
diff --git a/api-docs/Models/TagSummary.md b/api-docs/Models/TagSummary.md
index 50324417..bee584f1 100644
--- a/api-docs/Models/TagSummary.md
+++ b/api-docs/Models/TagSummary.md
@@ -5,6 +5,7 @@
|------------ | ------------- | ------------- | -------------|
| **id** | **String** | | [optional] [default to null] |
| **name** | **String** | | [optional] [default to null] |
+| **tagType** | [**TagType**](TagType.md) | | [optional] [default to null] |
| **questionCount** | **Integer** | | [optional] [default to null] |
| **photo** | **String** | | [optional] [default to null] |
diff --git a/api-docs/Models/UserProfile.md b/api-docs/Models/UserProfile.md
index 6901036d..145c95f4 100644
--- a/api-docs/Models/UserProfile.md
+++ b/api-docs/Models/UserProfile.md
@@ -19,6 +19,7 @@
| **answerCount** | **Integer** | | [optional] [default to null] |
| **answers** | [**List**](AnswerDetails.md) | | [optional] [default to null] |
| **questions** | [**List**](QuestionSummary.md) | | [optional] [default to null] |
+| **followedTags** | [**List**](TagSummary.md) | | [optional] [default to null] |
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
diff --git a/api-docs/Models/getBookmarkedQuestions_200_response.md b/api-docs/Models/getBookmarkedQuestions_200_response.md
new file mode 100644
index 00000000..6d84de91
--- /dev/null
+++ b/api-docs/Models/getBookmarkedQuestions_200_response.md
@@ -0,0 +1,10 @@
+# getBookmarkedQuestions_200_response
+## Properties
+
+| Name | Type | Description | Notes |
+|------------ | ------------- | ------------- | -------------|
+| **status** | **Integer** | Internal status code of the response. An HTTP 200 response with an internal 500 status code is an error response. Prioritize the inner status over the HTTP status. | [default to null] |
+| **data** | [**List**](QuestionSummary.md) | | [default to null] |
+
+[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
+
diff --git a/api-docs/Models/rateQuestion_200_response.md b/api-docs/Models/rateQuestion_200_response.md
new file mode 100644
index 00000000..1057874f
--- /dev/null
+++ b/api-docs/Models/rateQuestion_200_response.md
@@ -0,0 +1,10 @@
+# rateQuestion_200_response
+## Properties
+
+| Name | Type | Description | Notes |
+|------------ | ------------- | ------------- | -------------|
+| **status** | **Integer** | Internal status code of the response. An HTTP 200 response with an internal 500 status code is an error response. Prioritize the inner status over the HTTP status. | [default to null] |
+| **data** | [**QuestionRateResponseDto**](QuestionRateResponseDto.md) | | [default to null] |
+
+[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
+
diff --git a/api-docs/README.md b/api-docs/README.md
index 9f7d6913..3cba0da2 100644
--- a/api-docs/README.md
+++ b/api-docs/README.md
@@ -20,12 +20,14 @@ All URIs are relative to *http://localhost:5173/api/v1*
*AuthApi* | [**verifyEmail**](Apis/AuthApi.md#verifyemail) | **POST** /auth/verify-email | Verify user's email |
| *CodeExecutionApi* | [**executeCode**](Apis/CodeExecutionApi.md#executecode) | **POST** /execute-code | Execute a code snippet |
| *FeedApi* | [**getUserFeed**](Apis/FeedApi.md#getuserfeed) | **GET** /feed | Get user feed |
-| *QuestionsApi* | [**bookmarkQuestion**](Apis/QuestionsApi.md#bookmarkquestion) | **POST** /questions/{questionId}/bookmark | Bookmark a question |
+| *QuestionsApi* | [**bookmarkQuestion**](Apis/QuestionsApi.md#bookmarkquestion) | **POST** /questions/{questionId}/bookmarks | Bookmark a question |
*QuestionsApi* | [**createQuestion**](Apis/QuestionsApi.md#createquestion) | **POST** /questions | Create a new question |
*QuestionsApi* | [**deleteQuestion**](Apis/QuestionsApi.md#deletequestion) | **DELETE** /questions/{questionId} | Delete a question |
*QuestionsApi* | [**downvoteQuestion**](Apis/QuestionsApi.md#downvotequestion) | **POST** /questions/{questionId}/downvote | Downvote a question |
+*QuestionsApi* | [**getBookmarkedQuestions**](Apis/QuestionsApi.md#getbookmarkedquestions) | **GET** /questions/bookmarked | Get bookmarked questions |
*QuestionsApi* | [**getQuestionDetails**](Apis/QuestionsApi.md#getquestiondetails) | **GET** /questions/{questionId} | Get question details |
-*QuestionsApi* | [**removeQuestionBookmark**](Apis/QuestionsApi.md#removequestionbookmark) | **DELETE** /questions/{questionId}/bookmark | Remove bookmark from a question |
+*QuestionsApi* | [**rateQuestion**](Apis/QuestionsApi.md#ratequestion) | **POST** /questions/{id}/vote-difficulty | Rate a question's level of difficulty. |
+*QuestionsApi* | [**removeQuestionBookmark**](Apis/QuestionsApi.md#removequestionbookmark) | **DELETE** /questions/{questionId}/bookmarks | Remove bookmark from a question |
*QuestionsApi* | [**updateQuestion**](Apis/QuestionsApi.md#updatequestion) | **PUT** /questions/{questionId} | Update a question |
*QuestionsApi* | [**upvoteQuestion**](Apis/QuestionsApi.md#upvotequestion) | **POST** /questions/{questionId}/upvote | Upvote a question |
| *SearchApi* | [**searchQuestions**](Apis/SearchApi.md#searchquestions) | **GET** /search/questions | Search questions |
@@ -51,6 +53,7 @@ All URIs are relative to *http://localhost:5173/api/v1*
- [AuthToken](./Models/AuthToken.md)
- [CodeExecution](./Models/CodeExecution.md)
- [DifficultyLevel](./Models/DifficultyLevel.md)
+ - [DifficultyLevelRequestDto](./Models/DifficultyLevelRequestDto.md)
- [Error](./Models/Error.md)
- [ErrorResponseObject](./Models/ErrorResponseObject.md)
- [ExecutionResult](./Models/ExecutionResult.md)
@@ -60,6 +63,7 @@ All URIs are relative to *http://localhost:5173/api/v1*
- [NewTag](./Models/NewTag.md)
- [Profile](./Models/Profile.md)
- [QuestionDetails](./Models/QuestionDetails.md)
+ - [QuestionRateResponseDto](./Models/QuestionRateResponseDto.md)
- [QuestionSummary](./Models/QuestionSummary.md)
- [SuccessResponseObject](./Models/SuccessResponseObject.md)
- [SuccessResponseObject_data](./Models/SuccessResponseObject_data.md)
@@ -78,10 +82,12 @@ All URIs are relative to *http://localhost:5173/api/v1*
- [createQuestion_201_response](./Models/createQuestion_201_response.md)
- [createTag_201_response](./Models/createTag_201_response.md)
- [executeCode_200_response](./Models/executeCode_200_response.md)
+ - [getBookmarkedQuestions_200_response](./Models/getBookmarkedQuestions_200_response.md)
- [getQuestionAnswers_200_response](./Models/getQuestionAnswers_200_response.md)
- [getQuestionAnswers_200_response_allOf_data](./Models/getQuestionAnswers_200_response_allOf_data.md)
- [getUserFollowers_200_response](./Models/getUserFollowers_200_response.md)
- [getUserProfile_200_response](./Models/getUserProfile_200_response.md)
+ - [rateQuestion_200_response](./Models/rateQuestion_200_response.md)
- [resetPassword_request](./Models/resetPassword_request.md)
- [searchQuestions_200_response](./Models/searchQuestions_200_response.md)
- [searchQuestions_200_response_allOf_data](./Models/searchQuestions_200_response_allOf_data.md)
diff --git a/frontend/coverage/frontend/src/components/HighlightedQuestionCard.tsx.html b/frontend/coverage/frontend/src/components/HighlightedQuestionCard.tsx.html
new file mode 100644
index 00000000..c18ad8ef
--- /dev/null
+++ b/frontend/coverage/frontend/src/components/HighlightedQuestionCard.tsx.html
@@ -0,0 +1,286 @@
+
+
+
+
+
+ Code coverage report for frontend/src/components/HighlightedQuestionCard.tsx
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 4.54%
+ Statements
+ 2/44
+
+
+
+
+ 100%
+ Branches
+ 0/0
+
+
+
+
+ 0%
+ Functions
+ 0/2
+
+
+
+
+ 4.54%
+ Lines
+ 2/44
+
+
+
+
+
+ Press n or j to go to the next uncovered block, b, p or k for the previous block.
+
+
+
+ Filter:
+
+
+
+
+
+
+1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+65
+66
+67
+68 | 1x
+
+
+
+
+
+
+
+
+
+
+1x
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | import { Card } from "@/components/ui/card";
+import { QuestionSummary } from "@/services/api/programmingForumSchemas";
+
+import { ArrowRight, MessageSquare, Star, StarsIcon } from "lucide-react";
+import React from "react";
+import { Link } from "react-router-dom";
+import placeholderProfile from "@/assets/placeholder_profile.png";
+
+function capitalizeString(difficulty: string): React.ReactNode {
+ return difficulty.charAt(0).toUpperCase() + difficulty.slice(1);
+}
+export const HighlightedQuestionCard: React.FC<Partial<QuestionSummary>> = ({
+ id,
+ title,
+ content,
+ upvoteCount,
+ difficulty,
+ answerCount,
+ author,
+}) => {
+ return (
+ <Card className="border-none bg-blue-100 px-6 py-8 shadow-sm">
+ <div className="flex flex-col gap-6">
+ <h3 className="line-clamp-2 text-xl font-semibold text-gray-800">
+ {title}
+ </h3>
+ <p className="line-clamp-3 text-sm font-light text-gray-800">
+ {content}
+ </p>
+ <div className="flex flex-col gap-3 text-xs text-gray-700">
+ <div className="flex items-center gap-1">
+ <Star className="h-4 w-4" />
+ <span>{upvoteCount} votes</span>
+ </div>
+ <div className="flex items-center gap-1">
+ <MessageSquare className="h-4 w-4" />
+ <span>{answerCount} answers</span>
+ </div>
+ {difficulty && (
+ <div className="flex items-center gap-1">
+ <StarsIcon className="h-4 w-4" />
+ <span>{capitalizeString(difficulty)}</span>
+ </div>
+ )}
+ </div>
+ <div className="flex items-center justify-between">
+ {author && (
+ <Link to={`/users/${author.id}`} className="h-10 w-10">
+ <img
+ src={author?.profilePicture || placeholderProfile}
+ alt={author.name}
+ className="h-full w-full rounded-full object-cover"
+ />
+ </Link>
+ )}
+ <Link
+ to={`/question/${id}`}
+ className="flex items-center text-sm font-medium text-gray-800 hover:underline p-2"
+ >
+ Go to question
+ <ArrowRight className="ml-1 h-4 w-4" />
+ </Link>
+ </div>
+ </div>
+ </Card>
+ );
+};
+ |
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/components/AnswerCard.test.tsx b/frontend/src/components/AnswerCard.test.tsx
new file mode 100644
index 00000000..7192084f
--- /dev/null
+++ b/frontend/src/components/AnswerCard.test.tsx
@@ -0,0 +1,68 @@
+import { render, screen } from "@testing-library/react";
+import { MemoryRouter } from "react-router-dom";
+import { describe, expect, it } from "vitest";
+import { AnswerCard } from "./AnswerCard";
+
+describe("AnswerCard", () => {
+ const mockProps = {
+ id: 1,
+ title: "Test Answer",
+ content: "This is a test answer content",
+ votes: 5,
+ questionId: 123,
+ author: {
+ id: 456,
+ name: "Test Author",
+ profilePicture: "test-profile.jpg",
+ },
+ };
+
+ const renderWithRouter = (ui: React.ReactElement) => {
+ return render({ui});
+ };
+
+ it("renders answer card with correct content", () => {
+ renderWithRouter();
+
+ expect(screen.getByText(mockProps.title)).toBeInTheDocument();
+ expect(screen.getByText(mockProps.content)).toBeInTheDocument();
+ expect(screen.getByText(`${mockProps.votes} votes`)).toBeInTheDocument();
+ });
+
+ it("renders author profile picture", () => {
+ renderWithRouter();
+
+ const profilePicture = screen.getByAltText(
+ "Profile picture",
+ ) as HTMLImageElement;
+ expect(profilePicture).toBeInTheDocument();
+ expect(profilePicture.src).toContain(mockProps.author.profilePicture);
+ });
+
+ it("renders default profile picture when author picture is not provided", () => {
+ const propsWithoutPicture = {
+ ...mockProps,
+ author: { ...mockProps.author, profilePicture: "" },
+ };
+ renderWithRouter();
+
+ const profilePicture = screen.getByAltText(
+ "Profile picture",
+ ) as HTMLImageElement;
+ expect(profilePicture).toBeInTheDocument();
+ expect(profilePicture.src).toContain("placeholder_profile");
+ });
+
+ it("contains correct navigation links", () => {
+ renderWithRouter();
+
+ const authorLink = screen.getByRole("link", { name: /profile picture/i });
+ const answerLink = screen.getByRole("link", { name: /go to answer/i });
+
+ expect(authorLink).toHaveAttribute("href", `/users/${mockProps.author.id}`);
+ expect(answerLink).toHaveAttribute(
+ "href",
+ `/question/${mockProps.questionId}`,
+ );
+ });
+});
diff --git a/frontend/src/components/CodeSnippet.test.tsx b/frontend/src/components/CodeSnippet.test.tsx
new file mode 100644
index 00000000..bbf930ce
--- /dev/null
+++ b/frontend/src/components/CodeSnippet.test.tsx
@@ -0,0 +1,134 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+import { useExecuteCode } from "@/services/api/programmingForumComponents";
+import { UseMutationResult } from "@tanstack/react-query";
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { CodeSnippet } from "./CodeSnippet";
+
+// Mock the API hooks
+vi.mock("@/services/api/programmingForumComponents", () => ({
+ useExecuteCode: vi.fn(),
+}));
+
+// Mock clipboard API
+Object.assign(navigator, {
+ clipboard: {
+ writeText: vi.fn(),
+ },
+});
+
+describe("CodeSnippet", () => {
+ const mockProps = {
+ code: 'console.log("Hello, World!");',
+ language: "javascript",
+ };
+
+ beforeEach(() => {
+ vi.mocked(useExecuteCode).mockReturnValue({
+ mutate: vi.fn(),
+ isPending: false,
+ isSuccess: false,
+ isError: false,
+ data: undefined,
+ error: undefined,
+ } as unknown as UseMutationResult);
+ });
+
+ it("renders code snippet with correct language", () => {
+ render();
+
+ expect(screen.getByText("JavaScript Code Snippet")).toBeInTheDocument();
+ screen.getAllByRole("code").forEach((code) => {
+ expect(code).toHaveTextContent(mockProps.code);
+ });
+ });
+
+ it("handles code execution", () => {
+ const mockMutate = vi.fn();
+ vi.mocked(useExecuteCode).mockReturnValue({
+ mutate: mockMutate,
+ isPending: false,
+ isSuccess: false,
+ isError: false,
+ } as unknown as UseMutationResult);
+
+ render();
+
+ const executeButton = screen.getByRole("button", { name: /execute/i });
+ fireEvent.click(executeButton);
+
+ expect(mockMutate).toHaveBeenCalledWith({
+ body: {
+ code: mockProps.code,
+ language: mockProps.language,
+ },
+ });
+ });
+
+ it("shows loading state during execution", () => {
+ vi.mocked(useExecuteCode).mockReturnValue({
+ mutate: vi.fn(),
+ isPending: true,
+ isSuccess: false,
+ isError: false,
+ } as unknown as UseMutationResult);
+
+ render();
+
+ expect(screen.getByText("Executing...")).toBeInTheDocument();
+ });
+
+ it("shows success output after execution", () => {
+ vi.mocked(useExecuteCode).mockReturnValue({
+ mutate: vi.fn(),
+ isPending: false,
+ isSuccess: true,
+ isError: false,
+ data: {
+ data: {
+ output: "Hello, World!",
+ executionTime: 0.5,
+ },
+ },
+ } as unknown as UseMutationResult);
+
+ render();
+
+ expect(screen.getByText(/output \(in 0\.5s\):/i)).toBeInTheDocument();
+ expect(screen.getByText("Hello, World!")).toBeInTheDocument();
+ });
+
+ it("shows error message on execution failure", () => {
+ vi.mocked(useExecuteCode).mockReturnValue({
+ mutate: vi.fn(),
+ isPending: false,
+ isSuccess: false,
+ isError: true,
+ error: {
+ payload: {
+ error: {
+ errorMessage: "Execution failed",
+ },
+ },
+ },
+ } as unknown as UseMutationResult);
+
+ render();
+
+ expect(screen.getByText("Error:")).toBeInTheDocument();
+ expect(screen.getByText("Execution failed")).toBeInTheDocument();
+ });
+
+ it("copies code to clipboard when copy button is clicked", async () => {
+ render();
+
+ const copyButton = screen.getByRole("button", { name: /copy link/i });
+ fireEvent.click(copyButton);
+
+ await waitFor(() => {
+ // Since the code is broken up into multiple elements for syntax highlighting,
+ // we should verify the button click rather than the exact clipboard content
+ expect(navigator.clipboard.writeText).toHaveBeenCalled();
+ });
+ });
+});
diff --git a/frontend/src/components/CustomAnchor.test.tsx b/frontend/src/components/CustomAnchor.test.tsx
new file mode 100644
index 00000000..c8f39fab
--- /dev/null
+++ b/frontend/src/components/CustomAnchor.test.tsx
@@ -0,0 +1,101 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import { MemoryRouter, useNavigate } from "react-router-dom";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import CustomAnchor from "./CustomAnchor";
+
+// Mock useNavigate
+vi.mock("react-router-dom", async () => {
+ const actual = await vi.importActual("react-router-dom");
+ return {
+ ...actual,
+ useNavigate: vi.fn(),
+ };
+});
+
+describe("CustomAnchor", () => {
+ const mockNavigate = vi.fn();
+
+ beforeEach(() => {
+ vi.mocked(useNavigate).mockReturnValue(mockNavigate);
+ });
+
+ it("renders a span when no href is provided", () => {
+ render(
+
+ Test Link
+ ,
+ );
+
+ expect(screen.getByText("Test Link").tagName).toBe("SPAN");
+ });
+
+ it("renders an anchor with correct href when provided", () => {
+ render(
+
+ Test Link
+ ,
+ );
+
+ const link = screen.getByText("Test Link");
+ expect(link.tagName).toBe("A");
+ expect(link).toHaveAttribute("href", "#tag-123");
+ });
+
+ it("navigates to tag page when tag link is clicked", () => {
+ render(
+
+ Tag Link
+ ,
+ );
+
+ fireEvent.click(screen.getByText("Tag Link"));
+ expect(mockNavigate).toHaveBeenCalledWith("/tag/123");
+ });
+
+ it("navigates to question page when question link is clicked", () => {
+ render(
+
+ Question Link
+ ,
+ );
+
+ fireEvent.click(screen.getByText("Question Link"));
+ expect(mockNavigate).toHaveBeenCalledWith("/question/456");
+ });
+
+ it("sets correct title for tag links", () => {
+ render(
+
+ Tag Link
+ ,
+ );
+
+ expect(screen.getByText("Tag Link")).toHaveAttribute("title", "Tag: 123");
+ });
+
+ it("sets correct title for question links", () => {
+ render(
+
+ Question Link
+ ,
+ );
+
+ expect(screen.getByText("Question Link")).toHaveAttribute(
+ "title",
+ "Question: 456",
+ );
+ });
+
+ it("sets loading title for invalid href patterns", () => {
+ render(
+
+ Invalid Link
+ ,
+ );
+
+ expect(screen.getByText("Invalid Link")).toHaveAttribute(
+ "title",
+ "Loading...",
+ );
+ });
+});
diff --git a/frontend/src/components/ExerciseCard.test.tsx b/frontend/src/components/ExerciseCard.test.tsx
new file mode 100644
index 00000000..a82c7873
--- /dev/null
+++ b/frontend/src/components/ExerciseCard.test.tsx
@@ -0,0 +1,54 @@
+import { render, screen } from "@testing-library/react";
+import { MemoryRouter } from "react-router-dom";
+import { describe, expect, it } from "vitest";
+import { ExerciseCard } from "./ExerciseCard";
+
+describe("ExerciseCard", () => {
+ const mockProps = {
+ id: 1,
+ title: "Test Exercise",
+ description: "This is a test exercise description",
+ difficulty: "Medium",
+ tags: ["javascript", "algorithms", "arrays"],
+ link: "tracks/javascript/exercises/test-exercise",
+ };
+
+ const renderWithRouter = (ui: React.ReactElement) => {
+ return render({ui});
+ };
+
+ it("renders exercise card with correct content", () => {
+ renderWithRouter();
+
+ expect(screen.getByText(mockProps.title)).toBeInTheDocument();
+ expect(screen.getByText(mockProps.description)).toBeInTheDocument();
+ expect(screen.getByText(mockProps.difficulty)).toBeInTheDocument();
+ });
+
+ it("renders all tags correctly", () => {
+ renderWithRouter();
+
+ mockProps.tags.forEach((tag) => {
+ expect(screen.getByText(tag)).toBeInTheDocument();
+ });
+ });
+
+ it("contains correct exercism link", () => {
+ renderWithRouter();
+
+ const exerciseLink = screen.getByRole("link", { name: /go to exercise/i });
+ expect(exerciseLink).toHaveAttribute(
+ "href",
+ `https://exercism.org/${mockProps.link}`,
+ );
+ expect(exerciseLink).toHaveAttribute("target", "_blank");
+ });
+
+ it("displays difficulty label correctly", () => {
+ renderWithRouter();
+
+ const difficultyLabel = screen.getByText(/difficulty:/i);
+ expect(difficultyLabel).toBeInTheDocument();
+ expect(screen.getByText(mockProps.difficulty)).toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/components/SearchQuestionsList.test.tsx b/frontend/src/components/SearchQuestionsList.test.tsx
new file mode 100644
index 00000000..7149d5ec
--- /dev/null
+++ b/frontend/src/components/SearchQuestionsList.test.tsx
@@ -0,0 +1,167 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+import { useSearchQuestions } from "@/services/api/programmingForumComponents";
+import { DifficultyLevel } from "@/services/api/programmingForumSchemas";
+import { UseQueryResult } from "@tanstack/react-query";
+import { fireEvent, render, screen } from "@testing-library/react";
+import { MemoryRouter, useSearchParams } from "react-router-dom";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { SearchQuestionsList } from "./SearchQuestionsList";
+
+// Mock the API hooks
+vi.mock("@/services/api/programmingForumComponents", () => ({
+ useSearchQuestions: vi.fn(),
+}));
+
+// Mock react-router-dom
+vi.mock("react-router-dom", async () => {
+ const actual = await vi.importActual("react-router-dom");
+ return {
+ ...actual,
+ useSearchParams: vi.fn(),
+ };
+});
+
+describe("SearchQuestionsList", () => {
+ const mockQuestions = [
+ {
+ id: 1,
+ title: "Test Question 1",
+ content: "Content 1",
+ difficulty: "EASY" as DifficultyLevel,
+ upvoteCount: 5,
+ downvoteCount: 1,
+ answerCount: 2,
+ },
+ {
+ id: 2,
+ title: "Test Question 2",
+ content: "Content 2",
+ difficulty: "MEDIUM" as DifficultyLevel,
+ upvoteCount: 3,
+ downvoteCount: 0,
+ answerCount: 1,
+ },
+ ];
+
+ const mockSearchParams = new URLSearchParams();
+ mockSearchParams.set("q", "test");
+ mockSearchParams.set("sortBy", "recommended");
+
+ beforeEach(() => {
+ vi.mocked(useSearchParams).mockReturnValue([mockSearchParams, vi.fn()]);
+ vi.mocked(useSearchQuestions).mockReturnValue({
+ data: {
+ data: {
+ items: mockQuestions,
+ totalItems: mockQuestions.length,
+ },
+ },
+ isLoading: false,
+ error: null,
+ } as unknown as UseQueryResult);
+ });
+
+ it("renders search results correctly", () => {
+ render(
+
+
+ ,
+ );
+
+ expect(
+ screen.getByText(`Found ${mockQuestions.length} results`),
+ ).toBeInTheDocument();
+ mockQuestions.forEach((question) => {
+ expect(screen.getByText(question.title)).toBeInTheDocument();
+ expect(screen.getByText(question.content)).toBeInTheDocument();
+ });
+ });
+
+ it("displays loading state", () => {
+ vi.mocked(useSearchQuestions).mockReturnValue({
+ data: null,
+ isLoading: true,
+ error: null,
+ } as unknown as UseQueryResult);
+
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByText("Loading...")).toBeInTheDocument();
+ });
+
+ it("displays error state", () => {
+ const errorMessage = "Failed to fetch questions";
+
+ vi.mocked(useSearchQuestions).mockReturnValue({
+ data: null,
+ isLoading: false,
+ error: {
+ status: 500,
+ payload: {
+ status: 500,
+ error: {
+ errorMessage,
+ stackTrace: "Stack trace",
+ },
+ },
+ },
+ } as unknown as UseQueryResult);
+
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByText(errorMessage)).toBeInTheDocument();
+ });
+
+ it("displays empty state message when no results", () => {
+ vi.mocked(useSearchQuestions).mockReturnValue({
+ data: {
+ data: {
+ items: [],
+ totalItems: 0,
+ },
+ },
+ isLoading: false,
+ error: null,
+ } as unknown as UseQueryResult);
+
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByText("No questions found")).toBeInTheDocument();
+ expect(
+ screen.getByText("Try searching for specific topics or keywords."),
+ ).toBeInTheDocument();
+ });
+
+ it("handles difficulty filter change", () => {
+ render(
+
+
+ ,
+ );
+
+ const difficultyFilter = screen.getByRole("combobox");
+ fireEvent.click(difficultyFilter);
+ const mediumOption = screen.getAllByText("Medium");
+ fireEvent.click(mediumOption[1]);
+
+ expect(useSearchQuestions).toHaveBeenCalledWith(
+ expect.objectContaining({
+ queryParams: expect.objectContaining({
+ difficulty: "MEDIUM",
+ }),
+ }),
+ );
+ });
+});
diff --git a/frontend/src/components/Tag.test.tsx b/frontend/src/components/Tag.test.tsx
new file mode 100644
index 00000000..bc47aa8c
--- /dev/null
+++ b/frontend/src/components/Tag.test.tsx
@@ -0,0 +1,102 @@
+import { TagDetails } from "@/services/api/programmingForumSchemas";
+import useAuthStore from "@/services/auth";
+import { render, screen } from "@testing-library/react";
+import { MemoryRouter } from "react-router-dom";
+import { describe, expect, it, vi } from "vitest";
+import { Tag } from "./Tag";
+
+// Mock the auth store
+vi.mock("@/services/auth", () => ({
+ default: vi.fn(),
+}));
+
+describe("Tag", () => {
+ const mockTag = {
+ tagId: "123",
+ name: "JavaScript",
+ logoImage: "javascript-logo.png",
+ description: "A programming language",
+ };
+
+ it("renders tag information correctly", () => {
+ vi.mocked(useAuthStore).mockReturnValue({ token: null });
+
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByText(mockTag.name!)).toBeInTheDocument();
+ expect(screen.getByText(mockTag.description!)).toBeInTheDocument();
+ expect(
+ screen.getByAltText(`The logo image of ${mockTag.name}`),
+ ).toHaveAttribute("src", mockTag.logoImage);
+ });
+
+ it("shows 'See all questions' link", () => {
+ vi.mocked(useAuthStore).mockReturnValue({ token: null });
+
+ render(
+
+
+ ,
+ );
+
+ const link = screen.getByText(/see all questions/i);
+ expect(link).toBeInTheDocument();
+ expect(link).toHaveAttribute("href", `/tag/${mockTag.tagId}`);
+ });
+
+ it("shows create question link when authenticated", () => {
+ vi.mocked(useAuthStore).mockReturnValue({ token: "mock-token" });
+
+ render(
+
+
+ ,
+ );
+
+ const createLink = screen.getByRole("link", { name: /create question/i });
+ expect(createLink).toBeInTheDocument();
+ expect(createLink).toHaveAttribute(
+ "href",
+ `/questions/new?tagIds=${encodeURIComponent(mockTag.tagId)}`,
+ );
+ });
+
+ it("does not show create question link when not authenticated", () => {
+ vi.mocked(useAuthStore).mockReturnValue({ token: null });
+
+ render(
+
+
+ ,
+ );
+
+ const createLink = screen.queryByRole("link", { name: /create question/i });
+ expect(createLink).not.toBeInTheDocument();
+ });
+
+ it("renders image with correct alt and title attributes", () => {
+ vi.mocked(useAuthStore).mockReturnValue({ token: null });
+
+ render(
+
+
+ ,
+ );
+
+ const image = screen.getByAltText(`The logo image of ${mockTag.name}`);
+ expect(image).toHaveAttribute(
+ "title",
+ `alt:The logo image of ${mockTag.name}`,
+ );
+ expect(image).toHaveClass(
+ "h-full",
+ "w-full",
+ "rounded-2xl",
+ "object-cover",
+ );
+ });
+});
diff --git a/frontend/src/components/Tag.tsx b/frontend/src/components/Tag.tsx
index d12ea132..3530c8f1 100644
--- a/frontend/src/components/Tag.tsx
+++ b/frontend/src/components/Tag.tsx
@@ -39,6 +39,7 @@ export const Tag = ({
{!!token && (
diff --git a/frontend/src/components/Tags.test.tsx b/frontend/src/components/Tags.test.tsx
new file mode 100644
index 00000000..7c1be1d8
--- /dev/null
+++ b/frontend/src/components/Tags.test.tsx
@@ -0,0 +1,189 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+import { useSearchTags } from "@/services/api/programmingForumComponents";
+import { TagDetails } from "@/services/api/programmingForumSchemas";
+import { UseQueryResult } from "@tanstack/react-query";
+import { fireEvent, render, screen } from "@testing-library/react";
+import { MemoryRouter, useSearchParams } from "react-router-dom";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import TagsPage from "./Tags";
+
+// Mock the API hooks
+vi.mock("@/services/api/programmingForumComponents", () => ({
+ useSearchTags: vi.fn(),
+}));
+
+// Mock react-router-dom
+vi.mock("react-router-dom", async () => {
+ const actual = await vi.importActual("react-router-dom");
+ return {
+ ...actual,
+ useSearchParams: vi.fn(),
+ };
+});
+
+describe("TagsPage", () => {
+ const mockTags: Partial[] = [
+ {
+ tagId: "1",
+ name: "JavaScript",
+ description: "A programming language",
+ logoImage: "js-logo.png",
+ },
+ {
+ tagId: "2",
+ name: "Python",
+ description: "Another programming language",
+ logoImage: "python-logo.png",
+ },
+ ];
+
+ const mockSearchParams = new URLSearchParams();
+ mockSearchParams.set("q", "");
+
+ beforeEach(() => {
+ vi.mocked(useSearchParams).mockReturnValue([mockSearchParams, vi.fn()]);
+ vi.mocked(useSearchTags).mockReturnValue({
+ data: {
+ data: {
+ items: mockTags,
+ totalItems: mockTags.length,
+ },
+ },
+ isLoading: false,
+ error: null,
+ } as unknown as UseQueryResult);
+ });
+
+ it("renders tags list correctly", () => {
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByText("Tags")).toBeInTheDocument();
+ mockTags.forEach((tag) => {
+ expect(screen.getByText(tag.name!)).toBeInTheDocument();
+ expect(screen.getByText(tag.description!)).toBeInTheDocument();
+ expect(
+ screen.getByAltText(`The logo image of ${tag.name}`),
+ ).toHaveAttribute("src", tag.logoImage!);
+ });
+ });
+
+ it("displays loading state", () => {
+ vi.mocked(useSearchTags).mockReturnValue({
+ data: null,
+ isLoading: true,
+ error: null,
+ } as unknown as UseQueryResult);
+
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByText("Loading...")).toBeInTheDocument();
+ });
+
+ it("displays error state", () => {
+ const errorMessage = "Failed to fetch tags";
+ vi.mocked(useSearchTags).mockReturnValue({
+ data: null,
+ isLoading: false,
+ error: {
+ status: 500,
+ payload: {
+ status: 500,
+ error: {
+ errorMessage,
+ stackTrace: "Stack trace",
+ },
+ },
+ },
+ } as unknown as UseQueryResult);
+
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByText(errorMessage)).toBeInTheDocument();
+ });
+
+ it("shows create tag button", () => {
+ render(
+
+
+ ,
+ );
+
+ const createButton = screen.getByRole("link", { name: /create tag/i });
+ expect(createButton).toBeInTheDocument();
+ expect(createButton).toHaveAttribute("href", "/tags/new");
+ });
+
+ it("loads more tags when scrolling", () => {
+ const { container } = render(
+
+
+ ,
+ );
+
+ const infiniteScroll = container.querySelector("div");
+ fireEvent.scroll(infiniteScroll as Element);
+
+ expect(useSearchTags).toHaveBeenCalledWith(
+ expect.objectContaining({
+ queryParams: expect.objectContaining({
+ pageSize: 20,
+ }),
+ }),
+ expect.any(Object),
+ );
+ });
+
+ it("handles empty tags list", () => {
+ vi.mocked(useSearchTags).mockReturnValue({
+ data: {
+ data: {
+ items: [],
+ totalItems: 0,
+ },
+ },
+ isLoading: false,
+ error: null,
+ } as unknown as UseQueryResult);
+
+ render(
+
+
+ ,
+ );
+
+ expect(screen.queryByRole("article")).not.toBeInTheDocument();
+ });
+
+ it("updates search results when query changes", () => {
+ const searchParams = new URLSearchParams();
+ searchParams.set("q", "javascript");
+ vi.mocked(useSearchParams).mockReturnValue([searchParams, vi.fn()]);
+
+ render(
+
+
+ ,
+ );
+
+ expect(useSearchTags).toHaveBeenCalledWith(
+ expect.objectContaining({
+ queryParams: expect.objectContaining({
+ q: "javascript",
+ }),
+ }),
+ expect.any(Object),
+ );
+ });
+});
diff --git a/frontend/src/components/Tags.tsx b/frontend/src/components/Tags.tsx
index a1de0918..b12f9966 100644
--- a/frontend/src/components/Tags.tsx
+++ b/frontend/src/components/Tags.tsx
@@ -65,7 +65,7 @@ export default function TagsPage() {
size="icon"
className="rounded-full bg-red-500 text-white"
>
-
+
@@ -87,9 +87,7 @@ export default function TagsPage() {
{isLoading && (
-
+
Loading...
diff --git a/frontend/src/routes/create-question.test.tsx b/frontend/src/routes/create-question.test.tsx
new file mode 100644
index 00000000..d4d02431
--- /dev/null
+++ b/frontend/src/routes/create-question.test.tsx
@@ -0,0 +1,141 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+import {
+ useCreateQuestion,
+ useSearchTags,
+} from "@/services/api/programmingForumComponents";
+import {
+ UseMutationResult,
+ UseQueryResult,
+ useQueryClient,
+} from "@tanstack/react-query";
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { MemoryRouter, useNavigate, useSearchParams } from "react-router-dom";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import QuestionCreationPage from "./create-question";
+
+// Mock the API hooks
+vi.mock("@/services/api/programmingForumComponents", () => ({
+ useCreateQuestion: vi.fn(),
+ useSearchTags: vi.fn(),
+}));
+
+// Mock react-router-dom
+vi.mock("react-router-dom", async () => {
+ const actual = await vi.importActual("react-router-dom");
+ return {
+ ...actual,
+ useNavigate: vi.fn(),
+ useSearchParams: vi.fn(),
+ };
+});
+
+// Mock react-query
+vi.mock("@tanstack/react-query", async () => {
+ const actual = await vi.importActual("@tanstack/react-query");
+ return {
+ ...actual,
+ useQueryClient: vi.fn(),
+ };
+});
+
+describe("QuestionCreationPage", () => {
+ const mockNavigate = vi.fn();
+ const mockQueryClient = {
+ invalidateQueries: vi.fn(),
+ };
+ const mockCreateQuestion = vi.fn();
+ const mockSearchParams = new URLSearchParams();
+
+ const mockTags = [
+ { tagId: "1", name: "javascript" },
+ { tagId: "2", name: "python" },
+ ];
+
+ beforeEach(() => {
+ vi.mocked(useNavigate).mockReturnValue(mockNavigate);
+ vi.mocked(useQueryClient).mockReturnValue(mockQueryClient as any);
+ vi.mocked(useSearchParams).mockReturnValue([mockSearchParams, vi.fn()]);
+
+ vi.mocked(useCreateQuestion).mockReturnValue({
+ mutateAsync: mockCreateQuestion,
+ isLoading: false,
+ } as unknown as UseMutationResult
);
+
+ vi.mocked(useSearchTags).mockReturnValue({
+ data: {
+ data: {
+ items: mockTags,
+ },
+ },
+ isLoading: false,
+ } as unknown as UseQueryResult);
+ });
+
+ it("renders the form correctly", () => {
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByText("Create a new question")).toBeInTheDocument();
+ expect(screen.getByLabelText(/title/i)).toBeInTheDocument();
+ expect(screen.getByLabelText(/content/i)).toBeInTheDocument();
+ expect(
+ screen.getByRole("combobox", { name: /difficulty/i }),
+ ).toBeInTheDocument();
+ });
+
+ it("shows validation errors for empty fields", async () => {
+ render(
+
+
+ ,
+ );
+
+ // Submit without filling the form
+ fireEvent.click(screen.getByRole("button", { name: /submit/i }));
+
+ await waitFor(() => {
+ expect(screen.getByText("Title is required")).toBeInTheDocument();
+ expect(screen.getByText("Content is required")).toBeInTheDocument();
+ expect(
+ screen.getByText("At least one tag is required"),
+ ).toBeInTheDocument();
+ });
+ });
+
+ it("loads tags from URL parameters", () => {
+ const mockSearchParamsWithTags = new URLSearchParams();
+ mockSearchParamsWithTags.set("tagIds", "1,2");
+ vi.mocked(useSearchParams).mockReturnValue([
+ mockSearchParamsWithTags,
+ vi.fn(),
+ ]);
+
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByText("javascript")).toBeInTheDocument();
+ expect(screen.getByText("python")).toBeInTheDocument();
+ });
+
+ it("toggles preview mode", () => {
+ render(
+
+
+ ,
+ );
+
+ const previewButton = screen.getByRole("button", { name: /preview/i });
+ fireEvent.click(previewButton);
+
+ expect(screen.getByText("Preview")).toBeInTheDocument();
+
+ fireEvent.click(previewButton);
+ expect(screen.queryByText("Preview Mode")).not.toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/routes/create-question.tsx b/frontend/src/routes/create-question.tsx
index 9b509f46..a6e6f9b6 100644
--- a/frontend/src/routes/create-question.tsx
+++ b/frontend/src/routes/create-question.tsx
@@ -1,3 +1,4 @@
+import { ContentWithSnippets } from "@/components/ContentWithSnippets";
import { MultiSelect } from "@/components/multi-select";
import { Button } from "@/components/ui/button";
import {
@@ -8,23 +9,26 @@ import {
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
import { Textarea } from "@/components/ui/textarea";
import {
useCreateQuestion,
useSearchTags,
} from "@/services/api/programmingForumComponents";
-import { Info } from "lucide-react";
import { queryKeyFn } from "@/services/api/programmingForumContext";
import { TagDetails } from "@/services/api/programmingForumSchemas";
import { zodResolver } from "@hookform/resolvers/zod";
import { InvalidateQueryFilters, useQueryClient } from "@tanstack/react-query";
+import { Info } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { useNavigate, useSearchParams } from "react-router-dom";
-import { z } from "zod";
-import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import SyntaxHighlighter from "react-syntax-highlighter";
-import { ContentWithSnippets } from "@/components/ContentWithSnippets";
+import { z } from "zod";
// Schema validation for the form
const newQuestionSchema = z.object({
@@ -119,8 +123,8 @@ export default function QuestionCreationPage() {
return (
-
Create a new question
-
+ Create a new question
+
-