From 8e2d51660a98eb34a6b935e7fcccb20e071eb652 Mon Sep 17 00:00:00 2001 From: Stepan Date: Tue, 11 Nov 2025 21:45:51 +0100 Subject: [PATCH] Tests --- README.md | 4 + app/Http/Controllers/TestController.php | 267 ++++++++++++ app/Http/Resources/TestResource.php | 57 +++ app/Models/Test.php | 41 ++ app/Policies/TestPolicy.php | 66 +++ .../2025_11_11_191436_create_tests_table.php | 42 ++ routes/api.php | 10 + storage/api-docs/api-docs.json | 410 ++++++++++++++++++ 8 files changed, 897 insertions(+) create mode 100644 app/Http/Controllers/TestController.php create mode 100644 app/Http/Resources/TestResource.php create mode 100644 app/Models/Test.php create mode 100644 app/Policies/TestPolicy.php create mode 100644 database/migrations/2025_11_11_191436_create_tests_table.php diff --git a/README.md b/README.md index c9956b1..3e8f1fe 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,10 @@ HoshiAI-be — the backend part of the HoshiAI project for the Web Programming c * [L5-Swagger](https://github.com/DarkaOnLine/L5-Swagger) * [mobiledetect/mobiledetectlib](https://packagist.org/packages/mobiledetect/mobiledetectlib) +## External API +* [ip-api.com](http://ip-api.com/) +* [openai.com](https://openai.com/) + ## Installation 1. Install php and composer diff --git a/app/Http/Controllers/TestController.php b/app/Http/Controllers/TestController.php new file mode 100644 index 0000000..2ba8b4c --- /dev/null +++ b/app/Http/Controllers/TestController.php @@ -0,0 +1,267 @@ + 'required|string|max:255', + 'description' => 'required|string', + 'category_id' => 'nullable|exists:categories,id', + 'questions' => 'required|array|min:1', + 'questions.*' => 'exists:questions,id', + 'closed_at' => 'nullable|date', + ]; + + /** + * @OA\Get( + * path="/api/tests", + * summary="Get a list of tests (paginated)", + * description="Retrieve a paginated list of tests. Optionally filter by category_id.", + * tags={"Tests"}, + * @OA\Parameter( + * name="category_id", + * in="query", + * required=false, + * description="Filter tests by category ID", + * @OA\Schema(type="integer") + * ), + * @OA\Response( + * response=200, + * description="Paginated list of tests", + * @OA\JsonContent( + * type="object", + * @OA\Property( + * property="data", + * type="array", + * @OA\Items(ref="#/components/schemas/TestResource") + * ), + * @OA\Property( + * property="links", + * type="object", + * @OA\Property(property="first", type="string", example="http://api.example.com/tests?page=1"), + * @OA\Property(property="last", type="string", example="http://api.example.com/tests?page=10"), + * @OA\Property(property="prev", type="string", nullable=true, example=null), + * @OA\Property(property="next", type="string", nullable=true, example="http://api.example.com/tests?page=2") + * ), + * @OA\Property( + * property="meta", + * type="object", + * @OA\Property(property="current_page", type="integer", example=1), + * @OA\Property(property="from", type="integer", example=1), + * @OA\Property(property="last_page", type="integer", example=10), + * @OA\Property(property="path", type="string", example="http://api.example.com/tests"), + * @OA\Property(property="per_page", type="integer", example=15), + * @OA\Property(property="to", type="integer", example=15), + * @OA\Property(property="total", type="integer", example=150) + * ) + * ) + * ), + * @OA\Response( + * response=401, + * description="Unauthorized" + * ) + * ) + */ + public function index(Request $request) + { + $query = Test::with(['author', 'category']); + + if ($request->has('category_id')) { + $query->where('category_id', $request->query('category_id')); + } + + $questions = $query->paginate(self::PAGINATION_COUNT); + + return TestResource::collection($questions); + } + + /** + * @OA\Post( + * path="/api/tests", + * summary="Create a new test (only admin or creator)", + * description="Store a new test in the system (only admin or creator).", + * tags={"Tests"}, + * security={{"bearerAuth":{}}}, + * @OA\RequestBody( + * required=true, + * @OA\JsonContent( + * required={"title","closed_at","questions"}, + * @OA\Property(property="title", type="string", maxLength=255, example="Sample Test"), + * @OA\Property(property="description", type="string", nullable=true, example="Optional description"), + * @OA\Property(property="closed_at", type="string", format="date-time", nullable=true, example="2025-12-01T23:59:59Z"), + * @OA\Property(property="category_id", type="integer", nullable=true, example=3), + * @OA\Property( + * property="questions", + * type="array", + * description="Array of question IDs to attach to this test", + * @OA\Items(type="integer", example=1) + * ) + * ) + * ), + * @OA\Response( + * response=200, + * description="Test created successfully", + * @OA\JsonContent(ref="#/components/schemas/TestResource") + * ), + * @OA\Response( + * response=401, + * description="Unauthorized" + * ), + * @OA\Response( + * response=422, + * description="Validation error" + * ) + * ) + */ + public function store(Request $request) + { + $this->authorize('create', Test::class); + $fields = $request->validate(self::FIELD_RULES); + + $fields['author_id'] = $request->user()->id; + + $test = Test::create($fields); + $test->questions()->sync($fields['questions']); + + $test->load(['category', 'author', 'questions']); + Log::writeLog("Test '" . $test->title . "' is created by " . $request->user()->username); + return new TestResource($test); + } + + /** + * @OA\Get( + * path="/api/tests/{id}", + * summary="Get a single test", + * description="Retrieve a single test with its questions, category, and author", + * tags={"Tests"}, + * @OA\Parameter( + * name="id", + * in="path", + * required=true, + * description="Test ID", + * @OA\Schema(type="integer") + * ), + * @OA\Response( + * response=200, + * description="Test retrieved successfully", + * @OA\JsonContent(ref="#/components/schemas/TestResource") + * ), + * @OA\Response( + * response=404, + * description="Test not found" + * ) + * ) + */ + public function show(Test $test) + { + $test->load(['author', 'category', 'questions']); + return new TestResource($test); + } + + /** + * @OA\Put( + * path="/api/tests/{id}", + * summary="Update a test (only admin or creator)", + * description="Update a test's data and associated questions (only admin/creator).", + * tags={"Tests"}, + * security={{"bearerAuth":{}}}, + * @OA\Parameter( + * name="id", + * in="path", + * required=true, + * description="Test ID", + * @OA\Schema(type="integer") + * ), + * @OA\RequestBody( + * required=true, + * @OA\JsonContent( + * required={"title","questions"}, + * @OA\Property(property="title", type="string", maxLength=255, example="Updated Test Title"), + * @OA\Property(property="description", type="string", nullable=true, example="Optional description"), + * @OA\Property(property="category_id", type="integer", nullable=true, example=3), + * @OA\Property(property="closed_at", type="string", format="date-time", nullable=true, example="2025-12-01T23:59:59Z"), + * @OA\Property( + * property="questions", + * type="array", + * description="Array of question IDs to attach to this test", + * @OA\Items(type="integer", example=1) + * ) + * ) + * ), + * @OA\Response( + * response=200, + * description="Test updated successfully", + * @OA\JsonContent(ref="#/components/schemas/TestResource") + * ), + * @OA\Response( + * response=401, + * description="Unauthorized" + * ), + * @OA\Response( + * response=422, + * description="Validation error" + * ) + * ) + */ + public function update(Request $request, Test $test) + { + $this->authorize('update', $test); + $fields = $request->validate(self::FIELD_RULES); + + $test->update($fields); + $test->questions()->sync($fields['questions']); + + Log::writeLog("Test '" . $test->title . "' is updated by " . $request->user()->username); + + $test->load(['category', 'author', 'questions']); + return new TestResource($test); + } + + /** + * @OA\Delete( + * path="/api/tests/{id}", + * summary="Delete a test (only admin or creator)", + * description="Delete a test by ID (only admin or creator).", + * tags={"Tests"}, + * security={{"bearerAuth":{}}}, + * @OA\Parameter( + * name="id", + * in="path", + * required=true, + * description="Test ID", + * @OA\Schema(type="integer") + * ), + * @OA\Response( + * response=200, + * description="Test deleted successfully", + * @OA\JsonContent( + * type="object", + * @OA\Property(property="message", type="string", example="The test was deleted") + * ) + * ), + * @OA\Response( + * response=401, + * description="Unauthorized" + * ), + * @OA\Response( + * response=404, + * description="Test not found" + * ) + * ) + */ + public function destroy(Request $request, Test $test) + { + $this->authorize('delete', $test); + $test->delete(); + Log::writeLog("Test '" . $test->title . "' is deleted by " . $request->user()->username); + + return ['message' => 'The hitcount was deleted']; + } +} diff --git a/app/Http/Resources/TestResource.php b/app/Http/Resources/TestResource.php new file mode 100644 index 0000000..9c46bd3 --- /dev/null +++ b/app/Http/Resources/TestResource.php @@ -0,0 +1,57 @@ + $this->id, + 'title' => $this->title, + 'description' => $this->description, + + 'category_id' => $this->category_id, + 'category' => new CategoryResource($this->whenLoaded('category')), + + 'questions' => QuestionResource::collection($this->whenLoaded('questions')), + + 'author_id' => $this->author_id, + 'author' => new UserResource($this->whenLoaded('author')), + + 'closed_at' => $this->closed_at, + 'created_at' => $this->created_at, + 'updated_at' => $this->updated_at, + ]; + } +} diff --git a/app/Models/Test.php b/app/Models/Test.php new file mode 100644 index 0000000..d869565 --- /dev/null +++ b/app/Models/Test.php @@ -0,0 +1,41 @@ +belongsTo(Category::class); + } + public function author() + { + return $this->belongsTo(User::class); + } + + public function questions() + { + return $this->belongsToMany(Question::class) + ->withTimestamps(); + } +} \ No newline at end of file diff --git a/app/Policies/TestPolicy.php b/app/Policies/TestPolicy.php new file mode 100644 index 0000000..1229129 --- /dev/null +++ b/app/Policies/TestPolicy.php @@ -0,0 +1,66 @@ +type == 'admin' || $user->type == 'creator'; + } + + /** + * Determine whether the user can update the model. + */ + public function update(User $user, Test $question): bool + { + return $user->type == 'admin' || $question->user_id == $user->id; + } + + /** + * Determine whether the user can delete the model. + */ + public function delete(User $user, Test $question): bool + { + return $user->type == 'admin' || $question->user_id == $user->id; + } + + /** + * Determine whether the user can restore the model. + */ + public function restore(User $user, Test $test): bool + { + return false; + } + + /** + * Determine whether the user can permanently delete the model. + */ + public function forceDelete(User $user, Test $test): bool + { + return false; + } +} diff --git a/database/migrations/2025_11_11_191436_create_tests_table.php b/database/migrations/2025_11_11_191436_create_tests_table.php new file mode 100644 index 0000000..e1ac029 --- /dev/null +++ b/database/migrations/2025_11_11_191436_create_tests_table.php @@ -0,0 +1,42 @@ +id(); + $table->string('title'); + $table->string('description'); + $table->timestamp('closed_at')->nullable(); + $table->unsignedBigInteger('category_id')->nullable(); + $table->unsignedBigInteger('author_id')->nullable(); + + $table->foreign('author_id')->references('id')->on('users')->onDelete('set null'); + $table->foreign('category_id')->references('id')->on('categories')->onDelete('set null'); + $table->timestamps(); + }); + + Schema::create('question_test', function (Blueprint $table) { + $table->foreignId('test_id')->constrained()->cascadeOnDelete(); + $table->foreignId('question_id')->constrained()->cascadeOnDelete(); + $table->primary(['test_id', 'question_id']); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('tests'); + } +}; diff --git a/routes/api.php b/routes/api.php index fdc9c79..e3385d3 100644 --- a/routes/api.php +++ b/routes/api.php @@ -5,9 +5,19 @@ use App\Http\Controllers\CategoryController; use App\Http\Controllers\HitcountController; use App\Http\Controllers\LogController; use App\Http\Controllers\QuestionController; +use App\Http\Controllers\TestController; use App\Http\Controllers\UserController; use Illuminate\Support\Facades\Route; +// Tests +Route::get('tests', [TestController::class, 'index']); +Route::get('tests/{test}', [TestController::class, 'show']); +Route::middleware('auth:sanctum')->group(function () { + Route::post('tests', [TestController::class, 'store']); + Route::put('tests/{test}', [TestController::class, 'update']); + Route::delete('tests/{test}', [TestController::class, 'destroy']); +}); + // Questions Route::get('questions', [QuestionController::class, 'index']); Route::get('questions/{question}', [QuestionController::class, 'show']); diff --git a/storage/api-docs/api-docs.json b/storage/api-docs/api-docs.json index 8cef1c8..de62e44 100644 --- a/storage/api-docs/api-docs.json +++ b/storage/api-docs/api-docs.json @@ -1496,6 +1496,354 @@ ] } }, + "/api/tests": { + "get": { + "tags": [ + "Tests" + ], + "summary": "Get a list of tests (paginated)", + "description": "Retrieve a paginated list of tests. Optionally filter by category_id.", + "operationId": "5f539f69bb1d910182eb35136c5baa3a", + "parameters": [ + { + "name": "category_id", + "in": "query", + "description": "Filter tests by category ID", + "required": false, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Paginated list of tests", + "content": { + "application/json": { + "schema": { + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TestResource" + } + }, + "links": { + "properties": { + "first": { + "type": "string", + "example": "http://api.example.com/tests?page=1" + }, + "last": { + "type": "string", + "example": "http://api.example.com/tests?page=10" + }, + "prev": { + "type": "string", + "example": null, + "nullable": true + }, + "next": { + "type": "string", + "example": "http://api.example.com/tests?page=2", + "nullable": true + } + }, + "type": "object" + }, + "meta": { + "properties": { + "current_page": { + "type": "integer", + "example": 1 + }, + "from": { + "type": "integer", + "example": 1 + }, + "last_page": { + "type": "integer", + "example": 10 + }, + "path": { + "type": "string", + "example": "http://api.example.com/tests" + }, + "per_page": { + "type": "integer", + "example": 15 + }, + "to": { + "type": "integer", + "example": 15 + }, + "total": { + "type": "integer", + "example": 150 + } + }, + "type": "object" + } + }, + "type": "object" + } + } + } + }, + "401": { + "description": "Unauthorized" + } + } + }, + "post": { + "tags": [ + "Tests" + ], + "summary": "Create a new test (only admin or creator)", + "description": "Store a new test in the system (only admin or creator).", + "operationId": "7728a2f3dd87105d6d617df9a3f231d4", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "required": [ + "title", + "closed_at", + "questions" + ], + "properties": { + "title": { + "type": "string", + "maxLength": 255, + "example": "Sample Test" + }, + "description": { + "type": "string", + "example": "Optional description", + "nullable": true + }, + "closed_at": { + "type": "string", + "format": "date-time", + "example": "2025-12-01T23:59:59Z", + "nullable": true + }, + "category_id": { + "type": "integer", + "example": 3, + "nullable": true + }, + "questions": { + "description": "Array of question IDs to attach to this test", + "type": "array", + "items": { + "type": "integer", + "example": 1 + } + } + }, + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "Test created successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TestResource" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "422": { + "description": "Validation error" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/api/tests/{id}": { + "get": { + "tags": [ + "Tests" + ], + "summary": "Get a single test", + "description": "Retrieve a single test with its questions, category, and author", + "operationId": "7e3d8428f4df82c6d4ee7bd1d4d2128c", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Test ID", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Test retrieved successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TestResource" + } + } + } + }, + "404": { + "description": "Test not found" + } + } + }, + "put": { + "tags": [ + "Tests" + ], + "summary": "Update a test (only admin or creator)", + "description": "Update a test's data and associated questions (only admin/creator).", + "operationId": "ca1490751234c723e0a4a708afe16dd6", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Test ID", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "required": [ + "title", + "questions" + ], + "properties": { + "title": { + "type": "string", + "maxLength": 255, + "example": "Updated Test Title" + }, + "description": { + "type": "string", + "example": "Optional description", + "nullable": true + }, + "category_id": { + "type": "integer", + "example": 3, + "nullable": true + }, + "closed_at": { + "type": "string", + "format": "date-time", + "example": "2025-12-01T23:59:59Z", + "nullable": true + }, + "questions": { + "description": "Array of question IDs to attach to this test", + "type": "array", + "items": { + "type": "integer", + "example": 1 + } + } + }, + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "Test updated successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TestResource" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "422": { + "description": "Validation error" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "delete": { + "tags": [ + "Tests" + ], + "summary": "Delete a test (only admin or creator)", + "description": "Delete a test by ID (only admin or creator).", + "operationId": "92f76a68796679554c71a4659f62b296", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Test ID", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Test deleted successfully", + "content": { + "application/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "The test was deleted" + } + }, + "type": "object" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "404": { + "description": "Test not found" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "/api/users": { "get": { "tags": [ @@ -2011,6 +2359,64 @@ }, "type": "object" }, + "TestResource": { + "title": "Test", + "description": "Test resource", + "properties": { + "id": { + "type": "integer", + "example": 1 + }, + "title": { + "type": "string", + "example": "Sample Test" + }, + "description": { + "type": "string", + "example": "Optional description", + "nullable": true + }, + "category_id": { + "type": "integer", + "example": 3, + "nullable": true + }, + "category": { + "$ref": "#/components/schemas/CategoryResource" + }, + "questions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/QuestionResource" + } + }, + "author_id": { + "type": "integer", + "example": 2, + "nullable": true + }, + "author": { + "$ref": "#/components/schemas/UserResource" + }, + "closed_at": { + "type": "string", + "format": "date-time", + "example": "2025-11-11T10:30:00Z", + "nullable": true + }, + "created_at": { + "type": "string", + "format": "date-time", + "example": "2025-11-11T10:00:00Z" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "example": "2025-11-11T10:15:00Z" + } + }, + "type": "object" + }, "UserResource": { "properties": { "id": { @@ -2074,6 +2480,10 @@ "name": "Questions", "description": "Questions" }, + { + "name": "Tests", + "description": "Tests" + }, { "name": "Users", "description": "Users"