Compare commits

..

10 Commits

Author SHA1 Message Date
BillieFaiqul
e3611f3816 fix judul modul 2025-08-05 10:51:28 +07:00
BillieFaiqul
ba9b5a6d88 feat: round scores and factor attempts into ranking system 2025-07-04 18:39:34 +07:00
BillieFaiqul
bf65115b50 feat: add attempt limit validation for submissions 2025-07-04 18:31:15 +07:00
BillieFaiqul
bc28180777 fix 2025-06-27 14:26:28 +07:00
BillieFaiqul
457b8f3da8 fixing 2025-06-10 22:09:47 +07:00
BillieFaiqul
901e1aca33 feat: fix unzip 2025-05-26 21:35:46 +07:00
BillieFaiqul
61982c6275 fixing 2025-05-23 09:19:35 +07:00
BillieFaiqul
1093f01526 feat: add intro for new user 2025-05-18 16:26:53 +07:00
BillieFaiqul
c7c2a6221d feat: teacer user 2025-05-16 08:05:09 +07:00
BillieFaiqul
5db88aa8c5 feat: fix module pembelajaran 2025-05-14 09:03:05 +07:00
179 changed files with 13883 additions and 1370 deletions

View File

@ -58,7 +58,7 @@ public function signup(Request $request)
return redirect('/');
}
public function logoutt(Request $request): RedirectResponse
public function logout(Request $request): RedirectResponse
{
Auth::logout();
$request->session()->invalidate();

View File

@ -0,0 +1,26 @@
<?php
namespace App\Http\Controllers\NodeJS;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class NodeJSController extends Controller
{
public function index()
{
}
public function dashboardStudent() {
$topicCount = DB::table('projects')->count();
return view('dashboard_student', compact('topicCountt'));
}
public function dashboardTeacher() {
$topicCount = DB::table('projects')->count();
return view('dashboard_teacher', compact('topicCount'));
}
}

View File

@ -119,54 +119,42 @@ public function download(Request $request, $project_id)
if ($zipMedia) {
return response()->json($zipMedia->getUrl(), 200);
} else {
$tests_api = $project->getMedia('project_tests_api');
$tests_web = $project->getMedia('project_tests_web');
$tests_images = $project->getMedia('project_tests_images');
// Perbaiki collection name sesuai database
$tests = $project->getMedia('project_tests');
// Tambahkan pengecekan apakah ada file tests
if (count($tests) === 0) {
return response()->json(["message" => "no tests available"], 404);
}
$tempDir = storage_path('app/public/assets/projects/' . $project->title . '/zips');
if (!is_dir($tempDir)) mkdir($tempDir);
if (!is_dir($tempDir . '/tests')) mkdir($tempDir . '/tests');
if (!is_dir($tempDir . '/tests/api')) mkdir($tempDir . '/tests/api');
if (!is_dir($tempDir . '/tests/web')) mkdir($tempDir . '/tests/web');
if (!is_dir($tempDir . '/tests/web/images')) mkdir($tempDir . '/tests/web/images');
foreach ($tests_api as $test) {
// Copy semua file tests ke tempDir
foreach ($tests as $test) {
$path = $test->getPath();
$filename = $test->file_name;
copy($path, $tempDir . '/tests/api/' . $filename);
}
foreach ($tests_web as $test) {
$path = $test->getPath();
$filename = $test->file_name;
copy($path, $tempDir . '/tests/web/' . $filename);
}
foreach ($tests_images as $test) {
$path = $test->getPath();
$filename = $test->file_name;
copy($path, $tempDir . '/tests/web/images/' . $filename);
copy($path, $tempDir . '/' . $filename);
}
$zipPath = $tempDir . '/tests.zip';
$zip = new ZipArchive;
if ($zip->open($zipPath, ZipArchive::CREATE) === TRUE) {
$zip->addEmptyDir('api');
$zip->addEmptyDir('web');
$zip->addEmptyDir('web/images');
$api_files = Storage::files('public/assets/projects/' . $project->title . '/zips/tests/api');
foreach ($api_files as $file) {
$zip->addFile(storage_path('app/' . $file), 'api/' . basename($file));
$files = Storage::files('public/assets/projects/' . $project->title . '/zips');
foreach ($files as $file) {
// Skip jika file adalah zip itu sendiri
if (basename(storage_path('app/' . $file)) !== 'tests.zip') {
$zip->addFile(storage_path('app/' . $file), basename($file));
}
$api_files = Storage::files('public/assets/projects/' . $project->title . '/zips/tests/web');
foreach ($api_files as $file) {
$zip->addFile(storage_path('app/' . $file), 'web/' . basename($file));
}
$image_files = Storage::files('public/assets/projects/' . $project->title . '/zips/tests/web/images');
foreach ($image_files as $file) {
$zip->addFile(storage_path('app/' . $file), 'web/images/' . basename($file));
}
$zip->close();
Process::fromShellCommandline("rm -rf {$tempDir}/tests")->run();
// Hapus file individual setelah di-zip
foreach ($files as $file) {
if (basename(storage_path('app/' . $file)) !== 'tests.zip') {
unlink(storage_path('app/' . $file));
}
}
} else {
throw new Exception('Failed to create zip archive');
}

View File

@ -144,7 +144,6 @@ public function upload(Request $request, $project_id)
public function submit(Request $request)
{
try {
$request->validate([
'project_id' => 'required|exists:projects,id',
@ -161,34 +160,55 @@ public function submit(Request $request)
$submission = new Submission();
$submission->user_id = $request->user()->id;
$submission->project_id = $request->project_id;
if ($request->has('folder_path')) {
$submission->type = Submission::$FILE;
$submission->path = $request->folder_path;
$temporary_file = TemporaryFile::where('folder_path', $request->folder_path)->first();
if ($temporary_file) {
$path = storage_path('app/' . $request->folder_path . '/' . $temporary_file->file_name);
$submission->addMedia($path)->toMediaCollection('submissions', 'public_submissions_files');
// Debug: Cek apakah temporary file ada
if (!$temporary_file) {
\Log::error('TemporaryFile not found for folder_path: ' . $request->folder_path);
return response()->json(['message' => 'Temporary file not found'], 400);
}
$file_path = storage_path('app/' . $request->folder_path . '/' . $temporary_file->file_name);
// Debug: Cek apakah file fisik ada
if (!file_exists($file_path)) {
\Log::error('Physical file not found: ' . $file_path);
return response()->json(['message' => 'Physical file not found'], 400);
}
try {
$submission->addMedia($file_path)->toMediaCollection('submissions', 'nodejs_public_submissions_files');
} catch (\Exception $e) {
\Log::error('Media collection error: ' . $e->getMessage());
return response()->json(['message' => 'Failed to add media: ' . $e->getMessage()], 500);
}
// Cleanup
if ($this->is_dir_empty(storage_path('app/' . $request->folder_path))) {
rmdir(storage_path('app/' . $request->folder_path));
}
$temporary_file->delete();
}
} else {
$submission->type = Submission::$URL;
$submission->path = $request->github_url;
}
$submission->status = Submission::$PENDING;
$submission->start = now();
$submission->save();
return response()->json([
'message' => 'Submission created successfully',
'submission' => $submission,
], 201);
} catch (\Throwable $th) {
\Log::error('Submission error: ' . $th->getMessage() . ' | Line: ' . $th->getLine() . ' | File: ' . $th->getFile());
return response()->json([
'message' => 'Submission failed',
'error' => $th->getMessage(),
@ -662,9 +682,18 @@ public function restart(Request $request)
if ($request->submission_id == null) return response()->json([
'message' => 'Submission ID is required',
], 404);
$user = Auth::user();
$submission = Submission::where('id', $request->submission_id)->where('user_id', $user->id)->first();
if ($submission) {
// Check if attempts count is 3 or more
if ($submission->attempts >= 3) {
return response()->json([
'message' => 'Maximum attempts reached. Cannot restart submission.',
], 403);
}
$submission->createHistory("Submission has been restarted");
if ($submission->port != null) {
@ -677,6 +706,7 @@ public function restart(Request $request)
['rm', '-rf', $this->getTempDir($submission)],
];
}
// Delete temp directory
foreach ($commands as $command) {
if (!$this->is_dir_empty($this->getTempDir($submission))) {
@ -696,6 +726,7 @@ public function restart(Request $request)
'message' => 'Submission has been restarted successfully',
], 200);
}
return response()->json([
'message' => 'Submission not found',
], 404);
@ -706,9 +737,18 @@ public function changeSourceCode($submission_id)
{
$user = Auth::user();
$submission = Submission::where('id', $submission_id)->where('user_id', $user->id)->first();
if ($submission) {
return view('submissions.change_source_code', compact('submission'));
// Check if attempts count is 3 or more
if ($submission->attempts >= 3) {
return response()->json([
'message' => 'Maximum attempts reached. Cannot change source code.',
], 403);
}
return view('nodejs.submissions.change_source_code', compact('submission'));
}
return redirect()->route('submissions');
}
@ -733,12 +773,6 @@ public function update(Request $request)
});
}
// delete temp directory if is not empty
$tempDir = $this->getTempDir($submission);
if (!$this->is_dir_empty($tempDir)) {
Process::fromShellCommandline('rm -rf ' . $tempDir)->run();
}
if ($request->has('folder_path')) {
$submission->type = Submission::$FILE;
$submission->path = $request->folder_path;
@ -747,7 +781,7 @@ public function update(Request $request)
if ($temporary_file) {
$path = storage_path('app/' . $request->folder_path . '/' . $temporary_file->file_name);
$submission->addMedia($path)->toMediaCollection('submissions', 'public_submissions_files');
$submission->addMedia($path)->toMediaCollection('submissions', 'nodejs_public_submissions_files');
if ($this->is_dir_empty(storage_path('app/' . $request->folder_path))) {
rmdir(storage_path('app/' . $request->folder_path));
}

View File

@ -0,0 +1,95 @@
<?php
namespace App\Http\Controllers\NodeJS\Teacher;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class DashboardController extends Controller
{
public function index()
{
// Statistik yang sudah ada
$totalProyek = DB::table('projects')->count();
$totalSiswa = DB::table('users')
->where('role', 'student')
->count();
// Statistik baru
// 1. Hitung submission yang selesai (status 'completed' atau yang sesuai)
$submissionSelesai = DB::table('submissions')
->where('status', 'completed')
->count();
// 2. Hitung siswa yang telah mengirimkan minimal satu proyek
$siswaSubmisi = DB::table('submissions')
->join('users', 'users.id', '=', 'submissions.user_id')
->where('users.role', 'student')
->distinct('submissions.user_id')
->count('submissions.user_id');
// 3. Hitung total submission
$totalSubmisi = DB::table('submissions')->count();
// 4. Hitung submission yang gagal (status 'failed')
$submissionGagal = DB::table('submissions')
->where('status', 'failed')
->count();
// 5. Hitung tingkat keberhasilan (submission selesai vs total submission)
$tingkatKeberhasilan = $totalSubmisi > 0
? round(($submissionSelesai / $totalSubmisi) * 100)
: 0;
// 6. Hitung tingkat kegagalan (submission gagal vs total submission)
$tingkatKegagalan = $totalSubmisi > 0
? round(($submissionGagal / $totalSubmisi) * 100)
: 0;
// 7. Hitung proyek dengan minimal satu submission
$proyekDenganSubmisi = DB::table('submissions')
->distinct('project_id')
->count('project_id');
// 8. Hitung proyek tanpa submission
$proyekTanpaSubmisi = $totalProyek - $proyekDenganSubmisi;
// 9. Hitung persentase siswa yang telah membuat submission
$tingkatPartisipasiSiswa = $totalSiswa > 0
? round(($siswaSubmisi / $totalSiswa) * 100)
: 0;
// 10. Hitung submission yang sedang diproses
$submisiDalamProses = DB::table('submissions')
->where('status', 'processing')
->count();
// 11. Hitung submission yang tertunda
$submisiTertunda = DB::table('submissions')
->where('status', 'pending')
->count();
// 12. Hitung rata-rata percobaan per submission
$ratarataPecobaan = DB::table('submissions')
->avg('attempts');
$ratarataPecobaan = round($ratarataPecobaan, 1); // Bulatkan ke 1 angka desimal
return view('nodejs.dashboard-teacher.index', compact(
'totalProyek',
'totalSiswa',
'submissionSelesai',
'totalSubmisi',
'tingkatPartisipasiSiswa',
'submisiDalamProses',
'submisiTertunda',
'proyekDenganSubmisi',
'proyekTanpaSubmisi',
'siswaSubmisi',
'ratarataPecobaan',
'tingkatKeberhasilan',
'submissionGagal',
'tingkatKegagalan'
));
}
}

View File

@ -0,0 +1,249 @@
<?php
namespace App\Http\Controllers\NodeJS\Teacher;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class RankController extends Controller
{
/**
* Display the ranking page
*
* @return \Illuminate\Http\Response
*/
public function index()
{
return view('nodejs.rank-teacher.index');
}
/**
* Get the ranking data for a specific project
*
* @param Request $request
* @return \Illuminate\Http\JsonResponse
*/
public function getRankingByProject(Request $request)
{
$projectId = $request->input('project_id');
if (!$projectId) {
return response()->json([
'status' => 'error',
'message' => 'Project ID is required'
], 400);
}
// Get all submissions for this project
$submissions = DB::table('submissions')
->join('users', 'submissions.user_id', '=', 'users.id')
->where('submissions.project_id', $projectId)
->select(
'submissions.id',
'submissions.user_id',
'users.name as user_name',
'submissions.attempts',
'submissions.results',
'submissions.updated_at'
)
->get();
// Process the submissions to calculate scores
$rankings = [];
$processedUsers = [];
foreach ($submissions as $submission) {
$userId = $submission->user_id;
// Parse the results JSON
$resultsData = json_decode($submission->results, true);
if (!$resultsData) {
continue; // Skip invalid results
}
// Calculate total tests and passed tests
$totalTests = 0;
$passedTests = 0;
foreach ($resultsData as $testSuite) {
if (isset($testSuite['testResults'])) {
foreach ($testSuite['testResults'] as $result) {
if (isset($result['totalTests']) && isset($result['passedTests'])) {
$totalTests += $result['totalTests'];
$passedTests += $result['passedTests'];
}
}
}
}
// Calculate percentage score and round to nearest integer
$score = $totalTests > 0 ? ($passedTests / $totalTests) * 100 : 0;
$score = round($score, 0); // Round to nearest integer
// If we already processed this user, only keep the higher score
// If scores are equal, keep the one with fewer attempts
if (isset($processedUsers[$userId])) {
$shouldReplace = false;
if ($score > $processedUsers[$userId]['score']) {
$shouldReplace = true;
} elseif ($score == $processedUsers[$userId]['score'] && $submission->attempts < $processedUsers[$userId]['attempts']) {
$shouldReplace = true;
}
if ($shouldReplace) {
$processedUsers[$userId] = [
'user_id' => $userId,
'user_name' => $submission->user_name,
'attempts' => $submission->attempts,
'score' => $score,
'total_tests' => $totalTests,
'passed_tests' => $passedTests,
'submission_date' => $submission->updated_at,
];
}
} else {
$processedUsers[$userId] = [
'user_id' => $userId,
'user_name' => $submission->user_name,
'attempts' => $submission->attempts,
'score' => $score,
'total_tests' => $totalTests,
'passed_tests' => $passedTests,
'submission_date' => $submission->updated_at,
];
}
}
// Convert to array and sort by score (descending), then by attempts (ascending), then by submission date (ascending)
$rankings = array_values($processedUsers);
usort($rankings, function($a, $b) {
// First priority: score (higher is better)
if ($a['score'] != $b['score']) {
return $b['score'] - $a['score'];
}
// Second priority: attempts (fewer is better)
if ($a['attempts'] != $b['attempts']) {
return $a['attempts'] - $b['attempts'];
}
// Third priority: submission date (earlier is better)
return strtotime($a['submission_date']) - strtotime($b['submission_date']);
});
// Add rank information
$rank = 1;
foreach ($rankings as $key => $value) {
$rankings[$key]['rank'] = $rank++;
}
return response()->json([
'status' => 'success',
'data' => [
'rankings' => $rankings
]
]);
}
/**
* Get all projects for the ranking dropdown
*
* @return \Illuminate\Http\JsonResponse
*/
public function getProjects()
{
$projects = DB::table('projects')
->select('id', 'title') // Alias 'title' as 'name' to match frontend expectations
->orderBy('title')
->get();
return response()->json([
'status' => 'success',
'data' => [
'projects' => $projects
]
]);
}
/**
* Export ranking data to CSV
*
* @param Request $request
* @return \Symfony\Component\HttpFoundation\BinaryFileResponse
*/
public function exportRanking(Request $request)
{
$projectId = $request->input('project_id');
if (!$projectId) {
return response()->json([
'status' => 'error',
'message' => 'Project ID is required'
], 400);
}
// Get project name
$project = DB::table('projects')
->where('id', $projectId)
->first();
if (!$project) {
return response()->json([
'status' => 'error',
'message' => 'Project not found'
], 404);
}
// Get rankings data by calling the existing method
$rankingResponse = $this->getRankingByProject($request);
$content = json_decode($rankingResponse->getContent(), true);
if (!isset($content['data']['rankings'])) {
return response()->json([
'status' => 'error',
'message' => 'No ranking data available'
], 404);
}
$rankings = $content['data']['rankings'];
$filename = 'ranking_' . \Illuminate\Support\Str::slug($project->title) . '_' . date('Y-m-d') . '.csv';
// Create CSV headers
$headers = [
"Content-type" => "text/csv",
"Content-Disposition" => "attachment; filename=$filename",
"Pragma" => "no-cache",
"Cache-Control" => "must-revalidate, post-check=0, pre-check=0",
"Expires" => "0"
];
// Create the CSV file
$callback = function() use ($rankings) {
$file = fopen('php://output', 'w');
// Add CSV header row
fputcsv($file, ['Rank', 'Name', 'Attempts', 'Score (%)', 'Passed Tests', 'Total Tests', 'Submission Date']);
// Add data rows
foreach ($rankings as $ranking) {
fputcsv($file, [
$ranking['rank'],
$ranking['user_name'],
$ranking['attempts'],
$ranking['score'],
$ranking['passed_tests'],
$ranking['total_tests'],
$ranking['submission_date']
]);
}
fclose($file);
};
return response()->stream($callback, 200, $headers);
}
}

View File

@ -18,7 +18,8 @@ class TeacherMiddleware
public function handle(Request $request, Closure $next): Response
{
if ( Auth::user()->teacher !== "teacher" ) {
if ( Auth::user()->role !== "teacher" ) {
abort(403, "Unauthorized action.");
}

View File

@ -5,11 +5,11 @@
use App\Models\NodeJS\ExecutionStep;
use App\Models\NodeJS\Submission;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log;
use Symfony\Component\Process\Process;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

41
package-lock.json generated
View File

@ -4,6 +4,11 @@
"requires": true,
"packages": {
"": {
"dependencies": {
"alpinejs": "^3.14.9",
"select2": "^4.1.0-rc.0",
"tailwindcss": "^4.1.7"
},
"devDependencies": {
"axios": "^1.1.2",
"laravel-vite-plugin": "^0.7.5",
@ -362,6 +367,30 @@
"node": ">=12"
}
},
"node_modules/@vue/reactivity": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.1.5.tgz",
"integrity": "sha512-1tdfLmNjWG6t/CsPldh+foumYFo3cpyCHgBYQ34ylaMsJ+SNHQ1kApMIa8jN+i593zQuaw3AdWH0nJTARzCFhg==",
"license": "MIT",
"dependencies": {
"@vue/shared": "3.1.5"
}
},
"node_modules/@vue/shared": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.1.5.tgz",
"integrity": "sha512-oJ4F3TnvpXaQwZJNF3ZK+kLPHKarDmJjJ6jyzVNDKH9md1dptjC7lWR//jrGuLdek/U6iltWxqAnYOu8gCiOvA==",
"license": "MIT"
},
"node_modules/alpinejs": {
"version": "3.14.9",
"resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-3.14.9.tgz",
"integrity": "sha512-gqSOhTEyryU9FhviNqiHBHzgjkvtukq9tevew29fTj+ofZtfsYriw4zPirHHOAy9bw8QoL3WGhyk7QqCh5AYlw==",
"license": "MIT",
"dependencies": {
"@vue/reactivity": "~3.1.1"
}
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
@ -608,6 +637,12 @@
"fsevents": "~2.3.2"
}
},
"node_modules/select2": {
"version": "4.1.0-rc.0",
"resolved": "https://registry.npmjs.org/select2/-/select2-4.1.0-rc.0.tgz",
"integrity": "sha512-Hr9TdhyHCZUtwznEH2CBf7967mEM0idtJ5nMtjvk3Up5tPukOLXbHUNmh10oRfeNIhj+3GD3niu+g6sVK+gK0A==",
"license": "MIT"
},
"node_modules/source-map-js": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz",
@ -617,6 +652,12 @@
"node": ">=0.10.0"
}
},
"node_modules/tailwindcss": {
"version": "4.1.7",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.7.tgz",
"integrity": "sha512-kr1o/ErIdNhTz8uzAYL7TpaUuzKIE6QPQ4qmSdxnoX/lo+5wmUHQA6h3L5yIqEImSRnAAURDirLu/BgiXGPAhg==",
"license": "MIT"
},
"node_modules/vite": {
"version": "4.5.3",
"resolved": "https://registry.npmjs.org/vite/-/vite-4.5.3.tgz",

View File

@ -9,5 +9,10 @@
"axios": "^1.1.2",
"laravel-vite-plugin": "^0.7.5",
"vite": "^4.0.0"
},
"dependencies": {
"alpinejs": "^3.14.9",
"select2": "^4.1.0-rc.0",
"tailwindcss": "^4.1.7"
}
}

View File

@ -0,0 +1,166 @@
const request = require('supertest');
const mongoose = require('mongoose');
const {app} = require('../app');
const packages = require('../package.json');
const connectDB = require('../src/config/database');
describe("Pengujian konfigurasi aplikasi", () => {
it("Harus memiliki paket-paket development yang diperlukan", (done) => {
try {
expect(packages.devDependencies).toHaveProperty("jest");
} catch (error) {
throw new Error('Paket "jest" tidak ditemukan di devDependencies. Jalankan "npm i jest --save-dev".');
}
try {
expect(packages.devDependencies).toHaveProperty("nodemon");
} catch (error) {
throw new Error('Paket "nodemon" tidak ditemukan di devDependencies. Jalankan "npm i nodemon --save-dev".');
}
try {
expect(packages.devDependencies).toHaveProperty("supertest");
} catch (error) {
throw new Error('Paket "supertest" tidak ditemukan di devDependencies. Jalankan "npm i supertest --save-dev".');
}
try {
expect(packages.devDependencies).toHaveProperty("cross-env");
} catch (error) {
throw new Error('Paket "cross-env" tidak ditemukan di devDependencies. Jalankan "npm i cross-env --save-dev".');
}
done();
});
it("Harus memiliki paket-paket production yang diperlukan", (done) => {
try {
expect(packages.dependencies).toHaveProperty("dotenv");
} catch (error) {
throw new Error('Paket "dotenv" tidak ditemukan di dependencies. Jalankan "npm i dotenv --save".');
}
try {
expect(packages.dependencies).toHaveProperty("express");
} catch (error) {
throw new Error('Paket "express" tidak ditemukan di dependencies. Jalankan "npm i express --save".');
}
try {
expect(packages.dependencies).toHaveProperty("mongoose");
} catch (error) {
throw new Error('Paket "mongoose" tidak ditemukan di dependencies. Jalankan "npm i mongoose --save".');
}
done();
});
it("Harus memiliki nama yang benar", (done) => {
try {
expect(packages.name).toBe("restaurant-reservation");
} catch (error) {
throw new Error(`Nama aplikasi harus "restaurant-reservation", tetapi ditemukan "${packages.name}".`);
}
done();
});
it("Harus memiliki variabel lingkungan yang benar", (done) => {
try {
expect(process.env).toHaveProperty("MONGODB_URL");
} catch (error) {
throw new Error('Variabel lingkungan "MONGODB_URL" tidak ditemukan. Periksa file .env');
}
try {
expect(process.env).toHaveProperty("PORT");
} catch (error) {
throw new Error('Variabel lingkungan "PORT" tidak ditemukan. Periksa file .env');
}
try {
expect(process.env).toHaveProperty("MONGODB_URL_TEST");
} catch (error) {
throw new Error('Variabel lingkungan "MONGODB_URL_TEST" tidak ditemukan. Periksa file .env');
}
try {
expect(process.env).toHaveProperty("NODE_ENV");
} catch (error) {
throw new Error('Variabel lingkungan "NODE_ENV" tidak ditemukan. Periksa file .env');
}
done();
});
});
describe("Pengujian Middleware Aplikasi", () => {
it("Harus memiliki middleware yang diperlukan", (done) => {
let application_stack = [];
app._router.stack.forEach((element) => {
application_stack.push(element.name);
});
// Test for JSON middleware
expect(application_stack).toContain("jsonParser");
if (!application_stack.includes("jsonParser")) {
throw new Error("Aplikasi tidak menggunakan format JSON. Periksa file app.js");
}
// Test for Express middleware
expect(application_stack).toContain("expressInit");
if (!application_stack.includes("expressInit")) {
throw new Error("Aplikasi tidak menggunakan express framework. Periksa file app.js");
}
// Test for URL-encoded middleware
expect(application_stack).toContain("urlencodedParser");
if (!application_stack.includes("urlencodedParser")) {
throw new Error("Aplikasi tidak menggunakan format urlencoded. Periksa file app.js");
}
done();
});
});
describe('Pengujian Koneksi Database', () => {
it('Harus berhasil terhubung ke database MongoDB', async () => {
try {
const state = mongoose.connection.readyState;
expect(state).toBe(1);
} catch (error) {
throw new Error(`Gagal terhubung ke database: ${error.message}`);
}
});
});
describe('Pengujian API Utama', () => {
it('Harus mengembalikan pesan yang sesuai', async () => {
try {
const res = await request(app).get('/test');
expect(res.statusCode).toBe(200);
expect(res.body).toHaveProperty('status', 'success');
expect(res.body).toHaveProperty('message', 'Welcome to Restaurant Reservation API');
expect(res.body).toHaveProperty('version', '1.0.0');
} catch (error) {
throw new Error(`Terjadi kesalahan pada pengujian GET /: ${error.message}`);
}
});
});
async function disconnectDB() {
await mongoose.connection.close();
}
beforeAll(async () => {
try {
await connectDB();
dbConnected = true;
} catch (error) {
console.warn('⚠️ Gagal terhubung ke database:', error.message);
}
});
afterAll(async () => {
await disconnectDB();
});

View File

@ -0,0 +1,68 @@
const mongoose = require("mongoose");
const request = require("supertest");
const { app } = require("../app");
const Menu = require("../src/models/menuModel");
const connectDB = require("../src/config/database");
describe("Pengujian Integrasi - API Menu", () => {
beforeAll(async () => {
await connectDB();
await Menu.deleteMany({}); // Pastikan database dalam keadaan kosong sebelum pengujian
});
beforeEach(async () => {
await Menu.deleteMany({}); // Hapus semua data sebelum setiap pengujian
});
afterAll(async () => {
await mongoose.connection.close(); // Tutup koneksi database setelah semua pengujian selesai
});
it("harus berhasil membuat item menu baru melalui API", async () => {
const newItem = {
name: "Pizza",
description: "Pizza keju yang lezat",
price: 12.99,
category: "main",
isAvailable: true,
};
const res = await request(app).post("/createMenu").send(newItem);
expect(res.status).toBe(201);
expect(res.body.name).toBe(newItem.name);
expect(res.body.price).toBe(newItem.price);
});
it("harus mengambil semua item menu melalui API", async () => {
await Menu.create([
{ name: "Burger", price: 9.99, category: "main", isAvailable: true },
{ name: "Salad", price: 5.99, category: "appetizer", isAvailable: true },
]);
const res = await request(app).get("/menu");
expect(res.status).toBe(200);
expect(res.body.length).toBe(2);
});
it("harus mengambil item menu berdasarkan kategori melalui API", async () => {
await Menu.create([
{ name: "Steak", price: 19.99, category: "main", isAvailable: true },
{ name: "Soda", price: 2.99, category: "beverage", isAvailable: true },
]);
const res = await request(app).get("/menu/main");
expect(res.status).toBe(200);
expect(res.body.length).toBe(1);
expect(res.body[0].name).toBe("Steak");
});
it("harus mengembalikan 404 jika kategori tidak ditemukan", async () => {
const res = await request(app).get("/menu/dessert");
expect(res.status).toBe(404);
expect(res.body.error).toBe("Menu with category 'dessert' not found");
});
});

View File

@ -0,0 +1,89 @@
const Menu = require("../src/models/menuModel");
const menuController = require("../src/controllers/menuController");
// Mock model Menu
jest.mock("../src/models/menuModel");
describe("Pengujian Unit - Controller Menu", () => {
let req, res;
beforeEach(() => {
jest.clearAllMocks();
req = {
body: {
name: "Item Uji",
description: "Deskripsi Uji",
price: 9.99,
category: "main",
isAvailable: true,
},
params: {
category: "main",
},
};
res = {
status: jest.fn().mockReturnThis(),
json: jest.fn(),
};
});
// ✅ Pengujian Pembuatan Item Menu
describe("createMenuItem", () => {
it("harus berhasil membuat item menu", async () => {
const savedItem = { ...req.body, _id: "123" };
Menu.mockImplementation(() => ({
save: jest.fn().mockResolvedValue(savedItem),
}));
await menuController.createMenuItem(req, res);
expect(res.status).toHaveBeenCalledWith(201);
expect(res.json).toHaveBeenCalledWith(savedItem);
});
});
// ✅ Pengujian Mendapatkan Semua Item Menu
describe("getAllMenuItems", () => {
it("harus mengembalikan semua item menu dengan sukses", async () => {
const items = [
{ name: "Item 1", price: 9.99 },
{ name: "Item 2", price: 14.99 },
];
Menu.find.mockResolvedValue(items);
await menuController.getAllMenuItems(req, res);
expect(Menu.find).toHaveBeenCalledWith({});
expect(res.json).toHaveBeenCalledWith(items);
});
});
// ✅ Pengujian Mendapatkan Menu Berdasarkan Kategori
describe("getMenuByCategory", () => {
it("harus mengembalikan item untuk kategori yang valid", async () => {
const items = [
{ name: "Main Course 1", category: "main" },
{ name: "Main Course 2", category: "main" },
];
Menu.find.mockResolvedValue(items);
await menuController.getMenuByCategory(req, res);
expect(Menu.find).toHaveBeenCalledWith({ category: "main" });
expect(res.json).toHaveBeenCalledWith(items);
});
it("harus menangani kategori yang tidak ditemukan", async () => {
Menu.find.mockResolvedValue([]);
await menuController.getMenuByCategory(req, res);
expect(res.status).toHaveBeenCalledWith(404);
expect(res.json).toHaveBeenCalledWith({
error: "Menu with category 'main' not found",
});
});
});
});

View File

@ -0,0 +1,252 @@
const mongoose = require("mongoose");
const request = require("supertest");
const { app } = require("../app");
const Meja = require("../src/models/mejaModel");
const connectDB = require("../src/config/database");
describe("Pengujian Integrasi - API Meja", () => {
beforeAll(async () => {
await connectDB();
await Meja.deleteMany({}); // Pastikan database dalam keadaan kosong sebelum pengujian
});
beforeEach(async () => {
await Meja.deleteMany({}); // Hapus semua data sebelum setiap pengujian
});
afterAll(async () => {
await mongoose.connection.close(); // Tutup koneksi database setelah semua pengujian selesai
});
describe("POST add/meja", () => {
test("Harus berhasil membuat data meja baru", async () => {
const dataMeja = {
tableNumber: 1,
capacity: 4
};
const response = await request(app)
.post("/add/meja")
.send(dataMeja);
expect(response.status).toBe(201);
expect(response.body.success).toBe(true);
expect(response.body.data).toHaveProperty("tableNumber", 1);
expect(response.body.data).toHaveProperty("capacity", 4);
expect(response.body.data).toHaveProperty("status", "available");
});
test("Harus gagal ketika data tableNumber tidak disediakan", async () => {
const dataMeja = {
capacity: 4
};
const response = await request(app)
.post("/add/meja")
.send(dataMeja);
expect(response.status).toBe(400);
expect(response.body.success).toBe(false);
expect(response.body.error).toBeDefined();
});
test("Harus gagal ketika nomor meja sudah ada", async () => {
// Buat meja pertama
await Meja.create({
tableNumber: 5,
capacity: 4
});
// Coba buat meja dengan nomor yang sama
const response = await request(app)
.post("/add/meja")
.send({
tableNumber: 5,
capacity: 2
});
expect(response.status).toBe(400);
expect(response.body.success).toBe(false);
});
});
describe("GET /meja", () => {
test("Harus mendapatkan daftar meja kosong", async () => {
const response = await request(app).get("/meja");
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.data).toEqual([]);
});
test("Harus mendapatkan semua data meja", async () => {
// Tambahkan beberapa meja untuk pengujian
await Meja.create([
{ tableNumber: 1, capacity: 2 },
{ tableNumber: 2, capacity: 4 },
{ tableNumber: 3, capacity: 6 }
]);
const response = await request(app).get("/meja");
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.data.length).toBe(3);
expect(response.body.data[0]).toHaveProperty("tableNumber", 1);
expect(response.body.data[1]).toHaveProperty("tableNumber", 2);
expect(response.body.data[2]).toHaveProperty("tableNumber", 3);
});
});
describe("PUT /meja/:tableNumber/reserve", () => {
test("Harus berhasil memesan meja yang tersedia", async () => {
// Buat data meja terlebih dahulu
await Meja.create({
tableNumber: 10,
capacity: 4,
status: "available",
});
const response = await request(app)
.put("/meja/10/reserve")
.send({ customerName: "John Doe" }); // Tambahkan customerName
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.data).toHaveProperty("tableNumber", 10);
expect(response.body.data).toHaveProperty("status", "reserved");
expect(response.body.data).toHaveProperty("customerName", "John Doe"); // Verifikasi customerName
});
test("Harus gagal memesan meja yang tidak ada", async () => {
const response = await request(app)
.put("/meja/99/reserve")
.send({ customerName: "John Doe" }); // Tambahkan customerName
expect(response.status).toBe(404);
expect(response.body.success).toBe(false);
expect(response.body.error).toBe("Meja tidak tersedia");
});
test("Harus gagal memesan meja yang sudah dipesan", async () => {
// Buat meja yang sudah dipesan
await Meja.create({
tableNumber: 11,
capacity: 4,
status: "reserved",
});
const response = await request(app)
.put("/meja/11/reserve")
.send({ customerName: "John Doe" }); // Tambahkan customerName
expect(response.status).toBe(404);
expect(response.body.success).toBe(false);
expect(response.body.error).toBe("Meja tidak tersedia");
});
test("Harus gagal memesan meja jika customerName tidak disediakan", async () => {
// Buat data meja terlebih dahulu
await Meja.create({
tableNumber: 14,
capacity: 4,
status: "available",
});
const response = await request(app)
.put("/meja/14/reserve")
.send({}); // Tidak menyertakan customerName
expect(response.status).toBe(400);
expect(response.body.success).toBe(false);
expect(response.body.error).toBe("Nama pelanggan harus diisi");
});
});
describe("PUT /meja/:tableNumber/cancel", () => {
test("Harus berhasil membatalkan reservasi meja", async () => {
// Buat meja yang sudah dipesan
await Meja.create({
tableNumber: 12,
capacity: 6,
status: "reserved",
customerName: "John Doe", // Tambahkan customerName
});
const response = await request(app)
.put("/meja/12/cancel")
.send({});
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.data).toHaveProperty("tableNumber", 12);
expect(response.body.data).toHaveProperty("status", "available");
expect(response.body.message).toBe("Reservation for table 12 has been cancelled");
});
test("Harus gagal membatalkan reservasi meja yang tidak ada", async () => {
const response = await request(app)
.put("/meja/99/cancel")
.send({});
expect(response.status).toBe(404);
expect(response.body.success).toBe(false);
expect(response.body.error).toBe("Table not found or not currently reserved");
});
test("Harus gagal membatalkan reservasi meja dengan status available", async () => {
// Buat meja dengan status available
await Meja.create({
tableNumber: 13,
capacity: 4,
status: "available",
});
const response = await request(app)
.put("/meja/13/cancel")
.send({});
expect(response.status).toBe(404);
expect(response.body.success).toBe(false);
expect(response.body.error).toBe("Table not found or not currently reserved");
});
});
describe("Skenario Alur Pengelolaan Meja", () => {
test("Harus menjalankan alur lengkap: membuat, memesan, dan membatalkan reservasi meja", async () => {
// 1. Membuat meja baru
const createResponse = await request(app)
.post("/add/meja")
.send({ tableNumber: 20, capacity: 4 });
expect(createResponse.status).toBe(201);
expect(createResponse.body.data).toHaveProperty("tableNumber", 20);
expect(createResponse.body.data).toHaveProperty("status", "available");
// 2. Memesan meja
const reserveResponse = await request(app)
.put("/meja/20/reserve")
.send({ customerName: "John Doe" }); // Tambahkan customerName
expect(reserveResponse.status).toBe(200);
expect(reserveResponse.body.data).toHaveProperty("status", "reserved");
expect(reserveResponse.body.data).toHaveProperty("customerName", "John Doe"); // Verifikasi customerName
// 3. Membatalkan reservasi
const cancelResponse = await request(app)
.put("/meja/20/cancel")
.send({});
expect(cancelResponse.status).toBe(200);
expect(cancelResponse.body.data).toHaveProperty("status", "available");
// 4. Verifikasi status akhir
const getResponse = await request(app).get("/meja");
expect(getResponse.status).toBe(200);
expect(getResponse.body.data.length).toBe(1);
expect(getResponse.body.data[0]).toHaveProperty("tableNumber", 20);
expect(getResponse.body.data[0]).toHaveProperty("status", "available");
});
});
});

View File

@ -0,0 +1,307 @@
const mongoose = require('mongoose');
const Meja = require('../src/models/mejaModel');
const mejaController = require('../src/controllers/mejaController');
// Mock Express req dan res objects
const mockRequest = (body = {}, params = {}) => ({
body,
params
});
const mockResponse = () => {
const res = {};
res.status = jest.fn().mockReturnValue(res);
res.json = jest.fn().mockReturnValue(res);
return res;
};
// Mock the Meja model methods
jest.mock('../src/models/mejaModel');
describe('Meja Controller', () => {
afterEach(() => {
jest.clearAllMocks();
});
describe('createMeja', () => {
test('harus membuat meja baru dan mengembalikan status 201', async () => {
// Arrange
const mockMeja = {
tableNumber: 1,
capacity: 4,
status: 'available'
};
Meja.create.mockResolvedValue(mockMeja);
const req = mockRequest({ tableNumber: 1, capacity: 4 });
const res = mockResponse();
// Act
await mejaController.createMeja(req, res);
// Assert
expect(Meja.create).toHaveBeenCalledWith({
tableNumber: 1,
capacity: 4
});
expect(res.status).toHaveBeenCalledWith(201);
expect(res.json).toHaveBeenCalledWith({
success: true,
data: mockMeja
});
});
test('harus menangani error dan mengembalikan status 400', async () => {
// Arrange
const errorMessage = 'Validation error';
Meja.create.mockRejectedValue(new Error(errorMessage));
const req = mockRequest({ tableNumber: 1, capacity: 4 });
const res = mockResponse();
// Act
await mejaController.createMeja(req, res);
// Assert
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: errorMessage
});
});
});
describe('getAllMeja', () => {
test('harus mengembalikan semua meja dengan status 200', async () => {
// Arrange
const mockMejaList = [
{ tableNumber: 1, capacity: 4, status: 'available' },
{ tableNumber: 2, capacity: 2, status: 'reserved' }
];
// Setup chaining untuk find().sort()
const mockSort = jest.fn().mockResolvedValue(mockMejaList);
const mockFind = jest.fn().mockReturnValue({ sort: mockSort });
Meja.find = mockFind;
const req = mockRequest();
const res = mockResponse();
// Act
await mejaController.getAllMeja(req, res);
// Assert
expect(Meja.find).toHaveBeenCalled();
expect(mockSort).toHaveBeenCalledWith({ tableNumber: 1 });
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith({
success: true,
data: mockMejaList
});
});
test('harus menangani error dan mengembalikan status 400', async () => {
// Arrange
const errorMessage = 'Database error';
// Setup chaining untuk find().sort() yang mengembalikan error
const mockSort = jest.fn().mockRejectedValue(new Error(errorMessage));
const mockFind = jest.fn().mockReturnValue({ sort: mockSort });
Meja.find = mockFind;
const req = mockRequest();
const res = mockResponse();
// Act
await mejaController.getAllMeja(req, res);
// Assert
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: errorMessage
});
});
});
describe('reserveMeja', () => {
test('harus memesan meja yang tersedia dan mengembalikan status 200', async () => {
// Arrange
const tableNumber = '5';
const customerName = 'John Doe';
const mockMeja = {
tableNumber: 5,
capacity: 4,
status: 'reserved',
customerName: 'John Doe'
};
Meja.findOneAndUpdate.mockResolvedValue(mockMeja);
const req = mockRequest({ customerName }, { tableNumber });
const res = mockResponse();
// Act
await mejaController.reserveMeja(req, res);
// Assert
expect(Meja.findOneAndUpdate).toHaveBeenCalledWith(
{ tableNumber, status: 'available' },
{ status: 'reserved', customerName },
{ new: true }
);
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith({
success: true,
data: mockMeja
});
});
test('harus mengembalikan 404 ketika meja tidak tersedia', async () => {
// Arrange
const tableNumber = '5';
const customerName = 'John Doe';
Meja.findOneAndUpdate.mockResolvedValue(null);
const req = mockRequest({ customerName }, { tableNumber });
const res = mockResponse();
// Act
await mejaController.reserveMeja(req, res);
// Assert
expect(Meja.findOneAndUpdate).toHaveBeenCalledWith(
{ tableNumber, status: 'available' },
{ status: 'reserved', customerName },
{ new: true }
);
expect(res.status).toHaveBeenCalledWith(404);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: 'Meja tidak tersedia'
});
});
test('harus mengembalikan 400 ketika customerName tidak disediakan', async () => {
// Arrange
const tableNumber = '5';
const req = mockRequest({ tableNumber }, {});
const res = mockResponse();
// Act
await mejaController.reserveMeja(req, res);
// Assert
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: 'Nama pelanggan harus diisi'
});
});
test('harus menangani error dan mengembalikan status 400', async () => {
// Arrange
const tableNumber = '5';
const customerName = 'John Doe';
const errorMessage = 'Database error';
Meja.findOneAndUpdate.mockRejectedValue(new Error(errorMessage));
const req = mockRequest({ customerName }, { tableNumber });
const res = mockResponse();
// Act
await mejaController.reserveMeja(req, res);
// Assert
expect(Meja.findOneAndUpdate).toHaveBeenCalledWith(
{ tableNumber, status: 'available' },
{ status: 'reserved', customerName },
{ new: true }
);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: errorMessage
});
});
});
describe('cancelReservation', () => {
test('harus membatalkan reservasi dan mengembalikan status 200', async () => {
// Arrange
const tableNumber = '3';
const mockMeja = {
tableNumber: 3,
capacity: 4,
status: 'available',
customerName: ''
};
Meja.findOneAndUpdate.mockResolvedValue(mockMeja);
const req = mockRequest({}, { tableNumber });
const res = mockResponse();
// Act
await mejaController.cancelReservation(req, res);
// Assert
expect(Meja.findOneAndUpdate).toHaveBeenCalledWith(
{ tableNumber, status: 'reserved' },
{ status: 'available', customerName: '', updatedAt: expect.any(Number) },
{ new: true }
);
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith({
success: true,
message: `Reservation for table ${tableNumber} has been cancelled`,
data: mockMeja
});
});
test('harus mengembalikan 404 ketika meja tidak ditemukan atau tidak sedang dipesan', async () => {
// Arrange
const tableNumber = '3';
Meja.findOneAndUpdate.mockResolvedValue(null);
const req = mockRequest({}, { tableNumber });
const res = mockResponse();
// Act
await mejaController.cancelReservation(req, res);
// Assert
expect(res.status).toHaveBeenCalledWith(404);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: 'Table not found or not currently reserved'
});
});
test('harus menangani error dan mengembalikan status 400', async () => {
// Arrange
const tableNumber = '3';
const errorMessage = 'Database error';
Meja.findOneAndUpdate.mockRejectedValue(new Error(errorMessage));
const req = mockRequest({}, { tableNumber });
const res = mockResponse();
// Act
await mejaController.cancelReservation(req, res);
// Assert
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: errorMessage
});
});
});
});

View File

@ -0,0 +1,34 @@
const mongoose = require("mongoose");
const request = require("supertest");
const { app } = require("../../app");
const connectDB = require("../../src/config/database");
describe("Module 1 - Database & Basic API", () => {
beforeAll(async () => {
await connectDB();
});
afterAll(async () => {
await mongoose.connection.close();
});
describe("Database Connection", () => {
test("harus berhasil terhubung dengan MongoDB", () => {
const connectionState = mongoose.connection.readyState;
expect(connectionState).toBe(1);
});
});
describe("Test API Route", () => {
test("harus mengembalikan format respons yang benar dari rute pengujian", async () => {
const response = await request(app).get("/test");
expect(response.status).toBe(200);
expect(response.body).toEqual({
status: "success",
message: "Welcome to Restaurant Reservation API",
version: "1.0.0",
});
});
});
});

View File

@ -0,0 +1,128 @@
const mongoose = require("mongoose");
const request = require("supertest");
const { app } = require("../../app");
const Menu = require("../../src/models/menuModel");
const connectDB = require("../../src/config/database");
describe("Module 2 - Menu API", () => {
beforeAll(async () => {
await connectDB();
});
beforeEach(async () => {
await Menu.deleteMany({});
});
afterAll(async () => {
await mongoose.connection.close();
});
describe("POST /createMenu", () => {
test("harus berhasil membuat item menu baru", async () => {
const menuItem = {
name: "Nasi Goreng",
description: "Nasi goreng dengan telur mata sapi dan ayam kampung",
price: 25000,
category: "main",
isAvailable: true,
};
const response = await request(app).post("/createMenu").send(menuItem);
expect(response.status).toBe(200);
expect(response.body).toHaveProperty("name", "Nasi Goreng");
expect(response.body).toHaveProperty("price", 25000);
expect(response.body).toHaveProperty("category", "main");
});
});
describe("GET /menu", () => {
test("harus mengembalikan seluruh item menu", async () => {
const firstItem = await Menu.create({
name: "Nasi Putih",
price: 5000,
category: "main",
isAvailable: true,
});
const secondItem = await Menu.create({
name: "Kerupuk Udang",
price: 3000,
category: "appetizer",
isAvailable: true,
});
const response = await request(app).get("/menu");
expect(response.status).toBe(200);
expect(response.body.length).toBeGreaterThanOrEqual(2);
const nasiInResponse = response.body.find(
(item) => item._id === firstItem._id.toString(),
);
const kerupukInResponse = response.body.find(
(item) => item._id === secondItem._id.toString(),
);
expect(nasiInResponse).toBeDefined();
expect(kerupukInResponse).toBeDefined();
expect(nasiInResponse).toHaveProperty("name", "Nasi Putih");
expect(kerupukInResponse).toHaveProperty("name", "Kerupuk Udang");
});
test("harus mengembalikan array kosong jika tidak ada item menu", async () => {
await Menu.deleteMany({});
const response = await request(app).get("/menu");
expect(response.status).toBe(200);
expect(Array.isArray(response.body)).toBe(true);
expect(response.body.length).toBe(0);
});
});
describe("GET /menu/:category", () => {
test("harus mengembalikan item menu berdasarkan kategori yang ditentukan", async () => {
await Menu.deleteMany({});
await Menu.create([
{
name: "Nasi Rames",
price: 20000,
category: "main",
isAvailable: true,
},
{
name: "Gado-gado",
price: 15000,
category: "appetizer",
isAvailable: true,
},
{
name: "Soto Ayam",
price: 18000,
category: "main",
isAvailable: true,
},
]);
const response = await request(app).get("/menu/main");
expect(response.status).toBe(200);
expect(Array.isArray(response.body)).toBe(true);
expect(response.body.length).toBe(2);
response.body.forEach((item) => {
expect(item.category).toBe("main");
});
});
test("harus mengembalikan status 404 jika kategori tidak ditemukan", async () => {
const response = await request(app).get("/menu/bukanmakanan");
expect(response.status).toBe(404);
expect(response.body).toHaveProperty(
"error",
"Menu with category 'bukanmakanan' not found",
);
});
});
});

View File

@ -0,0 +1,177 @@
const mongoose = require("mongoose");
const request = require("supertest");
const { app } = require("../../app");
const Meja = require("../../src/models/mejaModel");
const connectDB = require("../../src/config/database");
describe("Module 3 - Meja/Table API", () => {
beforeAll(async () => {
await connectDB();
});
beforeEach(async () => {
await Meja.deleteMany({});
});
afterAll(async () => {
await mongoose.connection.close();
});
describe("POST /createMeja", () => {
test("harus berhasil membuat meja baru", async () => {
const newTable = {
tableNumber: 5,
capacity: 4,
};
const response = await request(app).post("/createMeja").send(newTable);
expect(response.status).toBe(201);
expect(response.body).toHaveProperty("success", true);
expect(response.body.data).toHaveProperty("tableNumber", 5);
expect(response.body.data).toHaveProperty("capacity", 4);
expect(response.body.data).toHaveProperty("status", "available");
});
});
describe("GET /meja", () => {
test("harus mengembalikan semua meja", async () => {
await Meja.create({ tableNumber: 1, capacity: 2 });
await Meja.create({ tableNumber: 2, capacity: 4 });
const response = await request(app).get("/meja");
expect(response.status).toBe(200);
expect(response.body).toHaveProperty("success", true);
expect(Array.isArray(response.body.data)).toBe(true);
expect(response.body.data.length).toBe(2);
const returnedTables = response.body.data;
const returnedTable1 = returnedTables.find((t) => t.tableNumber === 1);
const returnedTable2 = returnedTables.find((t) => t.tableNumber === 2);
expect(returnedTable1 || returnedTable2).toBeDefined();
});
});
describe("PUT /meja/:tableNumber/reserve", () => {
test("harus berhasil memesan meja yang tersedia", async () => {
const tableNumber = 10;
await Meja.create({
tableNumber: tableNumber,
capacity: 4,
status: "available",
});
const tableBeforeReserve = await Meja.findOne({ tableNumber });
expect(tableBeforeReserve).toBeDefined();
expect(tableBeforeReserve.status).toBe("available");
const response = await request(app)
.put(`/meja/${tableNumber}/reserve`)
.send({ customerName: "Susilo Bambang" });
if (response.status !== 200) {
console.log("Unexpected status code:", response.status);
console.log("Response body:", response.body);
}
expect(response.body).toHaveProperty("success");
if (response.body.success) {
expect(response.body.data).toHaveProperty("customerName");
expect(response.body.data).toHaveProperty("status", "reserved");
expect(response.body.data.customerName).toBe("Susilo Bambang");
}
});
test("harus mengembalikan status 400 jika nama pelanggan tidak disediakan", async () => {
await Meja.create({
tableNumber: 11,
capacity: 4,
status: "available",
});
const response = await request(app).put("/meja/11/reserve").send({});
expect(response.status).toBe(400);
expect(response.body).toHaveProperty("success", false);
expect(response.body).toHaveProperty(
"error",
"Nama pelanggan harus diisi",
);
});
test("harus mengembalikan status 404 jika meja tidak tersedia", async () => {
await Meja.create({
tableNumber: 12,
capacity: 4,
status: "reserved",
customerName: "Dewi Sartika",
});
const response = await request(app)
.put("/meja/12/reserve")
.send({ customerName: "Budi Sudarsono" });
expect(response.status).toBe(404);
expect(response.body).toHaveProperty("success", false);
expect(response.body).toHaveProperty("error", "Meja tidak tersedia");
});
});
describe("PUT /meja/:tableNumber/cancel", () => {
test("harus berhasil membatalkan pemesanan meja", async () => {
// Create a reserved table first with correct status and customerName
await Meja.create({
tableNumber: 15,
capacity: 4,
status: "reserved",
customerName: "Anita Wijaya",
});
// Verify that the table was created with the right status
const createdTable = await Meja.findOne({ tableNumber: 15 });
expect(createdTable).toBeDefined();
expect(createdTable.status).toBe("reserved");
const response = await request(app).put("/meja/15/cancel").send({});
if (response.status !== 200) {
console.log("Unexpected status code for cancel:", response.status);
console.log("Cancel response body:", response.body);
}
expect(response.body).toHaveProperty("success");
});
test("harus mengembalikan status 404 jika meja tidak ditemukan", async () => {
const response = await request(app).put("/meja/99/cancel").send({});
expect(response.status).toBe(404);
expect(response.body).toHaveProperty("success", false);
expect(response.body).toHaveProperty(
"error",
"Table not found or not currently reserved",
);
});
test("harus mengembalikan status 404 jika meja tidak dalam status dipesan", async () => {
// Create an available table first
await Meja.create({
tableNumber: 16,
capacity: 4,
status: "available",
});
const response = await request(app).put("/meja/16/cancel").send({});
expect(response.status).toBe(404);
expect(response.body).toHaveProperty("success", false);
expect(response.body).toHaveProperty(
"error",
"Table not found or not currently reserved",
);
});
});
});

View File

@ -0,0 +1,165 @@
const mongoose = require("mongoose");
const request = require("supertest");
const { app } = require("../../app");
const Order = require("../../src/models/orderModel");
const Meja = require("../../src/models/mejaModel");
const Menu = require("../../src/models/menuModel");
const connectDB = require("../../src/config/database");
describe("Module 4 - Order API", () => {
let testMenuId1, testMenuId2;
beforeAll(async () => {
await connectDB();
const menu1 = await Menu.create({
name: "Nasi Goreng Spesial",
description: "Nasi goreng dengan telur, ayam, dan sayuran segar",
price: 25000,
category: "main",
isAvailable: true,
});
const menu2 = await Menu.create({
name: "Sate Ayam",
description: "Sate ayam dengan bumbu kacang khas Indonesia",
price: 20000,
category: "appetizer",
isAvailable: true,
});
testMenuId1 = menu1._id;
testMenuId2 = menu2._id;
});
beforeEach(async () => {
await Order.deleteMany({});
await Meja.deleteMany({});
});
afterAll(async () => {
await Menu.deleteMany({});
await Order.deleteMany({});
await Meja.deleteMany({});
await mongoose.connection.close();
});
describe("POST /createOrders", () => {
test("harus berhasil membuat pesanan baru", async () => {
const table = await Meja.create({
tableNumber: 3,
capacity: 4,
status: "available",
});
const orderData = {
tableNumber: 3,
items: [
{ menuId: testMenuId1, quantity: 1 },
{ menuId: testMenuId2, quantity: 2 },
],
};
const response = await request(app).post("/createOrders").send(orderData);
expect(response.status).toBe(201);
expect(response.body).toHaveProperty("success", true);
expect(response.body.data).toHaveProperty("tableNumber", 3);
expect(response.body.data).toHaveProperty("total", 65000);
expect(response.body.data).toHaveProperty("status", "pending");
const updatedTable = await Meja.findOne({ tableNumber: 3 });
expect(updatedTable.status).toBe("reserved");
});
test("harus mengembalikan error ketika meja tidak tersedia", async () => {
const table = await Meja.create({
tableNumber: 4,
capacity: 4,
status: "reserved",
});
const orderData = {
tableNumber: 4,
items: [{ menuId: testMenuId1, quantity: 1 }],
};
const response = await request(app).post("/createOrders").send(orderData);
expect(response.status).toBe(400);
expect(response.body).toHaveProperty("success", false);
expect(response.body).toHaveProperty(
"error",
"Meja tidak tersedia atau sedang dipesan",
);
});
});
describe("GET /orders", () => {
test("harus mengembalikan daftar pesanan", async () => {
await Meja.create({
tableNumber: 2,
capacity: 4,
status: "available",
});
const orderData = {
tableNumber: 2,
items: [
{ menuId: testMenuId1, quantity: 1 },
{ menuId: testMenuId2, quantity: 2 },
],
};
await request(app).post("/createOrders").send(orderData);
const response = await request(app).get("/orders");
expect(response.status).toBe(200);
expect(response.body).toHaveProperty("success", true);
expect(Array.isArray(response.body.data)).toBe(true);
expect(response.body.data.length).toBeGreaterThan(0);
expect(response.body.data[0]).toHaveProperty("tableNumber", 2);
});
});
describe("PUT /orders/:orderId/status", () => {
test("harus mengubah status pesanan", async () => {
const table = await Meja.create({
tableNumber: 5,
capacity: 4,
status: "reserved",
});
const order = await Order.create({
tableNumber: 5,
items: [
{ menuId: testMenuId1, quantity: 1 },
{ menuId: testMenuId2, quantity: 2 },
],
total: 65000,
status: "pending",
});
const response = await request(app)
.put(`/orders/${order._id}/status`)
.send({ status: "completed" });
expect(response.status).toBe(200);
expect(response.body).toHaveProperty("success", true);
expect(response.body.data).toHaveProperty("status", "completed");
});
test("harus mengembalikan status 404 jika pesanan tidak ditemukan", async () => {
const nonExistentOrderId = new mongoose.Types.ObjectId();
const response = await request(app)
.put(`/orders/${nonExistentOrderId}/status`)
.send({ status: "completed" });
expect(response.status).toBe(404);
expect(response.body).toHaveProperty("success", false);
expect(response.body).toHaveProperty("error", "Pesanan tidak ditemukan");
});
});
});

View File

@ -0,0 +1,94 @@
const mongoose = require("mongoose");
const request = require("supertest");
const { app } = require("../../app");
const Order = require("../../src/models/orderModel");
const Meja = require("../../src/models/mejaModel");
const Menu = require("../../src/models/menuModel");
const connectDB = require("../../src/config/database");
describe("Module 5 - Error Handling", () => {
let testMenuId;
beforeAll(async () => {
await connectDB();
// Create a test menu item
const menu = await Menu.create({
name: "Nasi Goreng",
description: "Nasi goreng spesial dengan telur dan ayam",
price: 25000,
category: "main",
isAvailable: true,
});
testMenuId = menu._id;
});
beforeEach(async () => {
// Clean collections before each test
await Order.deleteMany({});
await Meja.deleteMany({});
});
afterAll(async () => {
await Menu.deleteMany({});
await Order.deleteMany({});
await Meja.deleteMany({});
await mongoose.connection.close();
});
describe("POST /createOrders - Error handling", () => {
test("harus mengembalikan status 400 ketika meja tidak tersedia", async () => {
await Meja.create({
tableNumber: 7,
capacity: 4,
status: "reserved",
customerName: "Budi Santoso",
});
const orderData = {
tableNumber: 7,
items: [{ menuId: testMenuId, quantity: 2 }],
};
const response = await request(app).post("/createOrders").send(orderData);
expect(response.status).toBe(400);
expect(response.body).toHaveProperty("success", false);
expect(response.body).toHaveProperty(
"error",
"Meja tidak tersedia atau sedang dipesan",
);
});
test("harus mengembalikan status 400 ketika item menu tidak valid", async () => {
// Create available table
await Meja.create({
tableNumber: 8,
capacity: 4,
status: "available",
});
const invalidMenuId = new mongoose.Types.ObjectId();
const orderData = {
tableNumber: 8,
items: [{ menuId: invalidMenuId, quantity: 1 }],
};
const response = await request(app).post("/createOrders").send(orderData);
expect(response.status).toBe(400);
expect(response.body).toHaveProperty("success", false);
expect(response.body.error).toContain("Beberapa item menu tidak valid");
});
});
describe("Global error handling", () => {
test("harus menangani rute yang tidak ada dengan status 404", async () => {
const response = await request(app).get("/non-existent-endpoint");
expect(response.status).toBe(404);
});
});
});

View File

@ -0,0 +1,103 @@
const mongoose = require("mongoose");
const connectDB = require("../../src/config/database");
const errorHandler = require("../../src/middleware/errorHandler");
const mockRequest = (body = {}, params = {}) => ({
body,
params,
});
const mockResponse = () => {
const res = {};
res.status = jest.fn().mockReturnValue(res);
res.json = jest.fn().mockReturnValue(res);
return res;
};
describe("Modul 1 - Unit Tests", () => {
beforeEach(() => {
jest.clearAllMocks();
console.error = jest.fn();
});
describe("Error Handler Middleware", () => {
test("harus menangani error dengan status code yang disediakan", () => {
const err = new Error("Bad Request Error");
err.statusCode = 400;
const req = mockRequest();
const res = mockResponse();
const next = jest.fn();
errorHandler(err, req, res, next);
expect(console.error).toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: "Bad Request Error",
});
});
test("harus menggunakan default status code 500 jika tidak disediakan", () => {
const err = new Error("Server Error Without Status Code");
const req = mockRequest();
const res = mockResponse();
const next = jest.fn();
errorHandler(err, req, res, next);
expect(console.error).toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: "Server Error Without Status Code",
});
});
});
describe("Custom Error Creation", () => {
test("harus dapat membuat custom error dengan status code", () => {
const createCustomError = (message, statusCode) => {
const error = new Error(message);
error.statusCode = statusCode;
return error;
};
const notFoundError = createCustomError("Resource not found", 404);
const validationError = createCustomError("Validation failed", 400);
expect(notFoundError).toBeInstanceOf(Error);
expect(notFoundError.message).toBe("Resource not found");
expect(notFoundError.statusCode).toBe(404);
expect(validationError).toBeInstanceOf(Error);
expect(validationError.message).toBe("Validation failed");
expect(validationError.statusCode).toBe(400);
});
test("harus dapat meneruskan error ke middleware error handler", () => {
const err = new Error("Test Error");
err.statusCode = 422;
const req = mockRequest();
const res = mockResponse();
const next = jest.fn();
next(err);
errorHandler(err, req, res, next);
expect(next).toHaveBeenCalledWith(err);
expect(res.status).toHaveBeenCalledWith(422);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: "Test Error",
});
});
});
describe("Express Middleware Structure", () => {
test("harus memiliki struktur errorHandler yang benar", () => {
expect(errorHandler).toBeInstanceOf(Function);
expect(errorHandler.length).toBe(4);
});
});
});

View File

@ -0,0 +1,155 @@
const Menu = require("../../src/models/menuModel");
const menuController = require("../../src/controllers/menuController");
jest.mock("../../src/models/menuModel");
jest.mock("../../src/controllers/menuController", () => {
const originalModule = jest.requireActual(
"../../src/controllers/menuController",
);
return {
...originalModule,
withCallback: (promise, callback) => {
return promise
.then((data) => callback(null, data))
.catch((err) => callback(err));
},
};
});
describe("Modul 2 - Menu Controller", () => {
let req, res;
beforeEach(() => {
jest.clearAllMocks();
req = {
body: {
name: "Nasi Goreng",
description: "Nasi goreng dengan telur mata sapi",
price: 25000,
category: "main",
isAvailable: true,
},
params: {
category: "main",
},
};
res = {
status: jest.fn().mockReturnThis(),
json: jest.fn(),
};
});
describe("createMenuItem", () => {
test("harus berhasil membuat menu baru", (done) => {
const mockSavedMenu = { ...req.body, _id: "menu123" };
Menu.mockImplementation(() => ({
save: jest.fn().mockResolvedValue(mockSavedMenu),
}));
res.status = jest.fn().mockReturnThis();
res.json = jest.fn().mockImplementation(() => {
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith(mockSavedMenu);
done();
});
menuController.createMenuItem(req, res);
});
test("harus menangani error saat pembuatan menu", (done) => {
const errorMessage = "Validation error";
Menu.mockImplementation(() => ({
save: jest.fn().mockRejectedValue(new Error(errorMessage)),
}));
res.status = jest.fn().mockReturnThis();
res.json = jest.fn().mockImplementation(() => {
expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith({ error: errorMessage });
done();
});
menuController.createMenuItem(req, res);
});
});
describe("getAllMenuItems", () => {
test("harus mengembalikan semua menu", (done) => {
const mockItems = [
{ name: "Nasi Goreng", price: 25000, category: "main" },
{ name: "Sate Ayam", price: 20000, category: "appetizer" },
];
Menu.find.mockResolvedValue(mockItems);
res.json = jest.fn().mockImplementation((data) => {
expect(Menu.find).toHaveBeenCalledWith({});
expect(data).toEqual(mockItems);
done();
return res;
});
menuController.getAllMenuItems(req, res);
});
test("harus menangani error saat mengambil menu", (done) => {
const errorMessage = "Database error";
Menu.find.mockRejectedValue(new Error(errorMessage));
res.status = jest.fn().mockReturnThis();
res.json = jest.fn().mockImplementation(() => {
expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith({ error: errorMessage });
done();
return res;
});
menuController.getAllMenuItems(req, res);
});
});
describe("getMenuByCategory", () => {
test("harus mengembalikan menu berdasarkan kategori", (done) => {
const mockCategoryItems = [
{ name: "Nasi Goreng", price: 25000, category: "main" },
{ name: "Mie Goreng", price: 23000, category: "main" },
];
Menu.find.mockResolvedValue(mockCategoryItems);
res.json = jest.fn().mockImplementation((data) => {
expect(Menu.find).toHaveBeenCalledWith({ category: "main" });
expect(data).toEqual(mockCategoryItems);
done();
return res;
});
menuController.getMenuByCategory(req, res);
});
test("harus mengembalikan 404 ketika kategori tidak ditemukan", (done) => {
Menu.find.mockResolvedValue([]);
res.status = jest.fn().mockReturnThis();
res.json = jest.fn().mockImplementation(() => {
expect(Menu.find).toHaveBeenCalledWith({ category: "main" });
expect(res.status).toHaveBeenCalledWith(404);
expect(res.json).toHaveBeenCalledWith({
error: "Menu with category 'main' not found",
});
done();
return res;
});
menuController.getMenuByCategory(req, res);
});
});
});

View File

@ -0,0 +1,242 @@
const Meja = require("../../src/models/mejaModel");
const mejaController = require("../../src/controllers/mejaController");
const mockRequest = (body = {}, params = {}) => ({
body,
params,
});
const mockResponse = () => {
const res = {};
res.status = jest.fn().mockReturnValue(res);
res.json = jest.fn().mockReturnValue(res);
return res;
};
jest.mock("../../src/models/mejaModel");
describe("Modul 3 - Meja Controller", () => {
afterEach(() => {
jest.clearAllMocks();
});
describe("createMeja", () => {
test("harus berhasil membuat meja baru", async () => {
const mockMeja = {
tableNumber: 5,
capacity: 4,
status: "available",
};
Meja.create.mockResolvedValue(mockMeja);
const req = mockRequest({ tableNumber: 5, capacity: 4 });
const res = mockResponse();
await mejaController.createMeja(req, res);
expect(Meja.create).toHaveBeenCalledWith({
tableNumber: 5,
capacity: 4,
});
expect(res.status).toHaveBeenCalledWith(201);
expect(res.json).toHaveBeenCalledWith({
success: true,
data: mockMeja,
});
});
test("harus mengembalikan error 400 ketika validasi gagal", async () => {
const errorMessage = "Validation error";
Meja.create.mockRejectedValue(new Error(errorMessage));
const req = mockRequest({ tableNumber: 5 });
const res = mockResponse();
await mejaController.createMeja(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: errorMessage,
});
});
});
describe("getAllMeja", () => {
test("harus mengembalikan semua data meja", async () => {
const mockMejaList = [
{ tableNumber: 1, capacity: 2, status: "available" },
{ tableNumber: 2, capacity: 4, status: "reserved" },
];
const mockSort = jest.fn().mockResolvedValue(mockMejaList);
const mockFind = jest.fn().mockReturnValue({ sort: mockSort });
Meja.find = mockFind;
const req = mockRequest();
const res = mockResponse();
await mejaController.getAllMeja(req, res);
expect(Meja.find).toHaveBeenCalled();
expect(mockSort).toHaveBeenCalledWith({ tableNumber: 1 });
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith({
success: true,
data: mockMejaList,
});
});
test("harus mengembalikan error 400 ketika terjadi kesalahan database", async () => {
const errorMessage = "Database error";
const mockSort = jest.fn().mockRejectedValue(new Error(errorMessage));
const mockFind = jest.fn().mockReturnValue({ sort: mockSort });
Meja.find = mockFind;
const req = mockRequest();
const res = mockResponse();
await mejaController.getAllMeja(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: errorMessage,
});
});
});
describe("reserveMeja", () => {
test("harus berhasil mereservasi meja", async () => {
const tableNumber = "10";
const customerName = "Susilo Bambang";
const mockMeja = {
tableNumber: 10,
capacity: 4,
status: "reserved",
customerName: "Susilo Bambang",
};
Meja.findOneAndUpdate.mockResolvedValue(mockMeja);
const req = mockRequest({ customerName }, { tableNumber });
const res = mockResponse();
await mejaController.reserveMeja(req, res);
expect(Meja.findOneAndUpdate).toHaveBeenCalledWith(
{ tableNumber, status: "available" },
{ status: "reserved", customerName },
{ new: true },
);
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith({
success: true,
data: mockMeja,
});
});
test("harus mengembalikan error 400 ketika nama pelanggan tidak disediakan", async () => {
const tableNumber = "10";
const req = mockRequest({}, { tableNumber });
const res = mockResponse();
await mejaController.reserveMeja(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: "Nama pelanggan harus diisi",
});
});
test("harus mengembalikan error 404 ketika meja tidak tersedia", async () => {
const tableNumber = "10";
const customerName = "Susilo Bambang";
Meja.findOneAndUpdate.mockResolvedValue(null);
const req = mockRequest({ customerName }, { tableNumber });
const res = mockResponse();
await mejaController.reserveMeja(req, res);
expect(res.status).toHaveBeenCalledWith(404);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: "Meja tidak tersedia",
});
});
});
describe("cancelReservation", () => {
test("harus berhasil membatalkan reservasi meja", async () => {
const tableNumber = "15";
const mockMeja = {
tableNumber: 15,
capacity: 4,
status: "available",
customerName: "",
};
Meja.findOneAndUpdate.mockResolvedValue(mockMeja);
const req = mockRequest({}, { tableNumber });
const res = mockResponse();
await mejaController.cancelReservation(req, res);
expect(Meja.findOneAndUpdate).toHaveBeenCalledWith(
{ tableNumber, status: "reserved" },
{
status: "available",
customerName: "",
updatedAt: expect.any(Number),
},
{ new: true },
);
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith({
success: true,
message: `Reservation for table ${tableNumber} has been cancelled`,
data: mockMeja,
});
});
test("harus mengembalikan error 404 ketika meja tidak ditemukan", async () => {
const tableNumber = "99";
Meja.findOneAndUpdate.mockResolvedValue(null);
const req = mockRequest({}, { tableNumber });
const res = mockResponse();
await mejaController.cancelReservation(req, res);
expect(res.status).toHaveBeenCalledWith(404);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: "Table not found or not currently reserved",
});
});
test("harus mengembalikan error 400 ketika terjadi kesalahan database", async () => {
const tableNumber = "15";
const errorMessage = "Database error";
Meja.findOneAndUpdate.mockRejectedValue(new Error(errorMessage));
const req = mockRequest({}, { tableNumber });
const res = mockResponse();
await mejaController.cancelReservation(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: errorMessage,
});
});
});
});

View File

@ -0,0 +1,236 @@
const mongoose = require("mongoose");
const Order = require("../../src/models/orderModel");
const Meja = require("../../src/models/mejaModel");
const Menu = require("../../src/models/menuModel");
const orderController = require("../../src/controllers/orderController");
const mockRequest = (body = {}, params = {}) => ({
body,
params,
});
const mockResponse = () => {
const res = {};
res.status = jest.fn().mockReturnValue(res);
res.json = jest.fn().mockReturnValue(res);
return res;
};
jest.mock("../../src/models/orderModel");
jest.mock("../../src/models/mejaModel");
jest.mock("../../src/models/menuModel");
const mockObjectId = new mongoose.Types.ObjectId();
describe("Modul 4 - Order Controller", () => {
let req, res, next;
beforeEach(() => {
jest.clearAllMocks();
req = {
body: {
tableNumber: 3,
items: [
{ menuId: mockObjectId.toString(), quantity: 1 },
{ menuId: mockObjectId.toString(), quantity: 2 },
],
},
params: {
orderId: mockObjectId.toString(),
},
};
res = mockResponse();
next = jest.fn();
});
describe("createOrder", () => {
test("harus berhasil membuat pesanan baru", async () => {
const mockMeja = {
tableNumber: 3,
status: "available",
};
const mockMenuItems = [
{ _id: mockObjectId, price: 25000 },
{ _id: mockObjectId, price: 20000 },
];
const mockSavedOrder = {
_id: mockObjectId,
tableNumber: 3,
items: req.body.items,
total: 65000,
status: "pending",
};
Meja.findOne.mockResolvedValue(mockMeja);
Menu.find.mockResolvedValue(mockMenuItems);
Order.prototype.save = jest.fn().mockResolvedValue(mockSavedOrder);
Meja.findOneAndUpdate.mockResolvedValue({ status: "reserved" });
await orderController.createOrder(req, res, next);
expect(Meja.findOne).toHaveBeenCalledWith({
tableNumber: 3,
status: "available",
});
expect(Menu.find).toHaveBeenCalled();
expect(Order.prototype.save).toHaveBeenCalled();
expect(Meja.findOneAndUpdate).toHaveBeenCalledWith(
{ tableNumber: 3 },
{ status: "reserved" },
);
expect(res.status).toHaveBeenCalledWith(201);
expect(res.json).toHaveBeenCalledWith({
success: true,
data: mockSavedOrder,
});
expect(next).not.toHaveBeenCalled();
});
test("harus menangani error ketika meja tidak tersedia", async () => {
Meja.findOne.mockResolvedValue(null);
await orderController.createOrder(req, res, next);
expect(Meja.findOne).toHaveBeenCalledWith({
tableNumber: 3,
status: "available",
});
expect(next).toHaveBeenCalledWith(expect.any(Error));
expect(next.mock.calls[0][0].message).toBe(
"Meja tidak tersedia atau sedang dipesan",
);
expect(next.mock.calls[0][0].statusCode).toBe(400);
});
test("harus menangani error ketika item menu tidak valid", async () => {
Meja.findOne.mockResolvedValue({ tableNumber: 3, status: "available" });
Menu.find.mockResolvedValue([]);
await orderController.createOrder(req, res, next);
expect(Menu.find).toHaveBeenCalled();
expect(next).toHaveBeenCalledWith(expect.any(Error));
expect(next.mock.calls[0][0].message).toBe(
"Beberapa item menu tidak valid",
);
expect(next.mock.calls[0][0].statusCode).toBe(400);
});
});
describe("getAllOrders", () => {
test("harus mengembalikan semua pesanan", async () => {
const mockOrders = [
{ _id: mockObjectId, tableNumber: 1, total: 50000, status: "pending" },
{
_id: mockObjectId,
tableNumber: 2,
total: 75000,
status: "completed",
},
];
const mockSort = jest.fn().mockResolvedValue(mockOrders);
Order.find = jest.fn().mockReturnValue({ sort: mockSort });
await orderController.getAllOrders(req, res);
expect(Order.find).toHaveBeenCalled();
expect(mockSort).toHaveBeenCalledWith({ createdAt: -1 });
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith({
success: true,
data: mockOrders,
});
});
test("harus menangani error saat mengambil pesanan", async () => {
const errorMessage = "Database error";
Order.find = jest.fn().mockImplementation(() => {
throw new Error(errorMessage);
});
await orderController.getAllOrders(req, res);
expect(Order.find).toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: errorMessage,
});
});
});
describe("updateOrderStatus", () => {
test("harus berhasil mengupdate status pesanan", async () => {
req.body = { status: "completed" };
const mockUpdatedOrder = {
_id: mockObjectId,
tableNumber: 3,
status: "completed",
};
Order.findByIdAndUpdate.mockResolvedValue(mockUpdatedOrder);
Meja.findOneAndUpdate.mockResolvedValue({ status: "available" });
await orderController.updateOrderStatus(req, res);
expect(Order.findByIdAndUpdate).toHaveBeenCalledWith(
mockObjectId.toString(),
{ status: "completed" },
{ new: true },
);
expect(Meja.findOneAndUpdate).toHaveBeenCalledWith(
{ tableNumber: 3 },
{ status: "available" },
);
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith({
success: true,
data: mockUpdatedOrder,
});
});
test("harus mengembalikan 404 ketika pesanan tidak ditemukan", async () => {
req.body = { status: "completed" };
Order.findByIdAndUpdate.mockResolvedValue(null);
await orderController.updateOrderStatus(req, res);
expect(Order.findByIdAndUpdate).toHaveBeenCalledWith(
mockObjectId.toString(),
{ status: "completed" },
{ new: true },
);
expect(res.status).toHaveBeenCalledWith(404);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: "Pesanan tidak ditemukan",
});
});
test("harus menangani error saat mengupdate status", async () => {
req.body = { status: "completed" };
const errorMessage = "Database error";
Order.findByIdAndUpdate.mockRejectedValue(new Error(errorMessage));
await orderController.updateOrderStatus(req, res);
expect(Order.findByIdAndUpdate).toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: errorMessage,
});
});
});
});

View File

@ -0,0 +1,142 @@
const errorHandler = require("../../src/middleware/errorHandler");
const mongoose = require("mongoose");
const Order = require("../../src/models/orderModel");
const Meja = require("../../src/models/mejaModel");
const Menu = require("../../src/models/menuModel");
const orderController = require("../../src/controllers/orderController");
const mockRequest = (body = {}, params = {}) => ({
body,
params,
});
const mockResponse = () => {
const res = {};
res.status = jest.fn().mockReturnValue(res);
res.json = jest.fn().mockReturnValue(res);
return res;
};
jest.mock("../../src/models/orderModel");
jest.mock("../../src/models/mejaModel");
jest.mock("../../src/models/menuModel");
const mockObjectId = new mongoose.Types.ObjectId();
describe("Modul 5 - Error Handling", () => {
let req, res, next;
beforeEach(() => {
jest.clearAllMocks();
console.error = jest.fn();
req = {
body: {
tableNumber: 7,
items: [{ menuId: mockObjectId.toString(), quantity: 2 }],
},
params: {},
};
res = mockResponse();
next = jest.fn();
});
describe("Error Handler Middleware", () => {
test("harus menangani error dengan status code dari error", () => {
const error = new Error("Validation error");
error.statusCode = 400;
errorHandler(error, req, res, next);
expect(console.error).toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: "Validation error",
});
});
test("harus menangani error tanpa status code dengan default 500", () => {
const error = new Error("Server error");
errorHandler(error, req, res, next);
expect(console.error).toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: "Server error",
});
});
});
describe("Order Controller Error Handling", () => {
test("harus menangani error meja tidak tersedia dengan status 400", async () => {
Meja.findOne.mockResolvedValue(null);
await orderController.createOrder(req, res, next);
expect(next).toHaveBeenCalledWith(expect.any(Error));
expect(next.mock.calls[0][0].message).toBe(
"Meja tidak tersedia atau sedang dipesan",
);
expect(next.mock.calls[0][0].statusCode).toBe(400);
});
test("harus menangani error menu tidak valid dengan status 400", async () => {
Meja.findOne.mockResolvedValue({ tableNumber: 7, status: "available" });
Menu.find.mockResolvedValue([]);
await orderController.createOrder(req, res, next);
expect(next).toHaveBeenCalledWith(expect.any(Error));
expect(next.mock.calls[0][0].message).toBe(
"Beberapa item menu tidak valid",
);
expect(next.mock.calls[0][0].statusCode).toBe(400);
});
test("harus menangani exception dengan middleware error", async () => {
const errorMessage = "Database connection failed";
Meja.findOne.mockImplementation(() => {
throw new Error(errorMessage);
});
await orderController.createOrder(req, res, next);
expect(next).toHaveBeenCalledWith(expect.any(Error));
expect(next.mock.calls[0][0].message).toBe(errorMessage);
});
test("harus mengubah error menjadi format error response", () => {
const error = new Error("Custom error");
error.statusCode = 422;
errorHandler(error, req, res, next);
expect(res.status).toHaveBeenCalledWith(422);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: "Custom error",
});
});
});
describe("Custom Error Creation", () => {
test("harus membuat error dengan statusCode yang tepat", async () => {
const createError = (message, statusCode) => {
const error = new Error(message);
error.statusCode = statusCode;
return error;
};
const error = createError("Resource not found", 404);
expect(error).toBeInstanceOf(Error);
expect(error.message).toBe("Resource not found");
expect(error.statusCode).toBe(404);
});
});
});

View File

@ -0,0 +1,34 @@
const mongoose = require("mongoose");
const request = require("supertest");
const { app } = require("../../app");
const connectDB = require("../../src/config/database");
describe("Module 1 - Database & Basic API", () => {
beforeAll(async () => {
jest.spyOn(console, 'log').mockImplementation(() => { });
await connectDB();
});
afterAll(async () => {
await mongoose.connection.close();
});
describe("Database Connection", () => {
test("harus berhasil terhubung dengan MongoDB", () => {
const connectionState = mongoose.connection.readyState;
expect(connectionState).toBe(1);
});
});
describe("Test API Route", () => {
test("harus mengembalikan format respons yang benar dari rute pengujian", async () => {
const response = await request(app).get("/test");
expect(response.status).toBe(200);
expect(response.body).toEqual({
message: "Welcome to Restaurant Reservation API",
});
});
});
});

View File

@ -0,0 +1,130 @@
const mongoose = require("mongoose");
const request = require("supertest");
const { app } = require("../../app");
const Menu = require("../../src/models/menuModel");
const connectDB = require("../../src/config/database");
describe("Module 2 - Menu API", () => {
beforeAll(async () => {
jest.spyOn(console, 'log').mockImplementation(() => { });
await connectDB();
});
beforeEach(async () => {
await Menu.deleteMany({});
});
afterAll(async () => {
await mongoose.connection.close();
});
describe("POST /createMenu", () => {
test("harus berhasil membuat item menu baru", async () => {
const menuItem = {
name: "Nasi Goreng",
description: "Nasi goreng dengan telur mata sapi dan ayam kampung",
price: 25000,
category: "main",
isAvailable: true,
};
const response = await request(app).post("/createMenu").send(menuItem);
expect(response.status).toBe(201);
expect(response.body).toHaveProperty("name", "Nasi Goreng");
expect(response.body).toHaveProperty("price", 25000);
expect(response.body).toHaveProperty("category", "main");
});
});
describe("GET /menu", () => {
test("harus mengembalikan seluruh item menu", async () => {
const firstItem = await Menu.create({
name: "Nasi Putih",
price: 5000,
category: "main",
isAvailable: true,
});
const secondItem = await Menu.create({
name: "Kerupuk Udang",
price: 3000,
category: "appetizer",
isAvailable: true,
});
const response = await request(app).get("/menu");
expect(response.status).toBe(200);
expect(response.body.length).toBeGreaterThanOrEqual(2);
const nasiInResponse = response.body.find(
(item) => item._id === firstItem._id.toString(),
);
const kerupukInResponse = response.body.find(
(item) => item._id === secondItem._id.toString(),
);
expect(nasiInResponse).toBeDefined();
expect(kerupukInResponse).toBeDefined();
expect(nasiInResponse).toHaveProperty("name", "Nasi Putih");
expect(kerupukInResponse).toHaveProperty("name", "Kerupuk Udang");
});
test("harus mengembalikan array kosong jika tidak ada item menu", async () => {
await Menu.deleteMany({});
const response = await request(app).get("/menu");
expect(response.status).toBe(200);
expect(Array.isArray(response.body)).toBe(true);
expect(response.body.length).toBe(0);
});
});
describe("GET /menu/:category", () => {
test("harus mengembalikan item menu berdasarkan kategori yang ditentukan", async () => {
await Menu.deleteMany({});
await Menu.create([
{
name: "Nasi Rames",
price: 20000,
category: "main",
isAvailable: true,
},
{
name: "Gado-gado",
price: 15000,
category: "appetizer",
isAvailable: true,
},
{
name: "Soto Ayam",
price: 18000,
category: "main",
isAvailable: true,
},
]);
const response = await request(app).get("/menu/main");
expect(response.status).toBe(200);
expect(Array.isArray(response.body)).toBe(true);
expect(response.body.length).toBe(2);
response.body.forEach((item) => {
expect(item.category).toBe("main");
});
});
test("harus mengembalikan status 404 jika kategori tidak ditemukan", async () => {
const response = await request(app).get("/menu/bukanmakanan");
expect(response.status).toBe(404);
expect(response.body).toHaveProperty(
"error",
"Menu with category 'bukanmakanan' not found",
);
});
});
});

View File

@ -0,0 +1,180 @@
const mongoose = require("mongoose");
const request = require("supertest");
const { app } = require("../../app");
const Meja = require("../../src/models/mejaModel");
const connectDB = require("../../src/config/database");
describe("Module 3 - Meja/Table API", () => {
beforeAll(async () => {
jest.spyOn(console, 'log').mockImplementation(() => { });
jest.spyOn(console, 'error').mockImplementation(() => { });
await connectDB();
});
beforeEach(async () => {
await Meja.deleteMany({});
});
afterAll(async () => {
await mongoose.connection.close();
});
describe("POST /createMeja", () => {
test("harus berhasil membuat meja baru", async () => {
const newTable = {
tableNumber: 5,
capacity: 4,
};
const response = await request(app).post("/createMeja").send(newTable);
expect(response.status).toBe(201);
expect(response.body).toHaveProperty("success", true);
expect(response.body.data).toHaveProperty("tableNumber", 5);
expect(response.body.data).toHaveProperty("capacity", 4);
expect(response.body.data).toHaveProperty("status", "available");
});
});
describe("GET /meja", () => {
test("harus mengembalikan semua meja", async () => {
await Meja.create({ tableNumber: 1, capacity: 2 });
await Meja.create({ tableNumber: 2, capacity: 4 });
const response = await request(app).get("/meja");
expect(response.status).toBe(200);
expect(response.body).toHaveProperty("success", true);
expect(Array.isArray(response.body.data)).toBe(true);
expect(response.body.data.length).toBe(2);
const returnedTables = response.body.data;
const returnedTable1 = returnedTables.find((t) => t.tableNumber === 1);
const returnedTable2 = returnedTables.find((t) => t.tableNumber === 2);
expect(returnedTable1 || returnedTable2).toBeDefined();
});
});
describe("PUT /meja/:tableNumber/reserve", () => {
test("harus berhasil memesan meja yang tersedia", async () => {
const tableNumber = 10;
await Meja.create({
tableNumber: tableNumber,
capacity: 4,
status: "available",
});
const tableBeforeReserve = await Meja.findOne({ tableNumber });
expect(tableBeforeReserve).toBeDefined();
expect(tableBeforeReserve.status).toBe("available");
const response = await request(app)
.put(`/meja/${tableNumber}/reserve`)
.send({ customerName: "Susilo Bambang" });
if (response.status !== 200) {
console.log("Unexpected status code:", response.status);
console.log("Response body:", response.body);
}
expect(response.body).toHaveProperty("success");
if (response.body.success) {
expect(response.body.data).toHaveProperty("customerName");
expect(response.body.data).toHaveProperty("status", "reserved");
expect(response.body.data.customerName).toBe("Susilo Bambang");
}
});
test("harus mengembalikan status 400 jika nama pelanggan tidak disediakan", async () => {
await Meja.create({
tableNumber: 11,
capacity: 4,
status: "available",
});
const response = await request(app).put("/meja/11/reserve").send({});
expect(response.status).toBe(400);
expect(response.body).toHaveProperty("success", false);
expect(response.body).toHaveProperty(
"error",
"Nama pelanggan harus diisi",
);
});
test("harus mengembalikan status 404 jika meja tidak tersedia", async () => {
await Meja.create({
tableNumber: 12,
capacity: 4,
status: "reserved",
customerName: "Dewi Sartika",
});
const response = await request(app)
.put("/meja/12/reserve")
.send({ customerName: "Budi Sudarsono" });
expect(response.status).toBe(404);
expect(response.body).toHaveProperty("success", false);
expect(response.body).toHaveProperty("error", "Meja tidak tersedia");
});
});
describe("PUT /meja/:tableNumber/cancel", () => {
test("harus berhasil membatalkan pemesanan meja", async () => {
// Create a reserved table first with correct status and customerName
await Meja.create({
tableNumber: 15,
capacity: 4,
status: "reserved",
customerName: "Anita Wijaya",
});
// Verify that the table was created with the right status
const createdTable = await Meja.findOne({ tableNumber: 15 });
expect(createdTable).toBeDefined();
expect(createdTable.status).toBe("reserved");
const response = await request(app).put("/meja/15/cancel").send({});
if (response.status !== 200) {
console.log("Unexpected status code for cancel:", response.status);
console.log("Cancel response body:", response.body);
}
expect(response.body).toHaveProperty("success");
});
test("harus mengembalikan status 404 jika meja tidak ditemukan", async () => {
const response = await request(app).put("/meja/99/cancel").send({});
expect(response.status).toBe(404);
expect(response.body).toHaveProperty("success", false);
expect(response.body).toHaveProperty(
"error",
"Table not found or not currently reserved",
);
});
test("harus mengembalikan status 404 jika meja tidak dalam status dipesan", async () => {
// Create an available table first
await Meja.create({
tableNumber: 16,
capacity: 4,
status: "available",
});
const response = await request(app).put("/meja/16/cancel").send({});
expect(response.status).toBe(404);
expect(response.body).toHaveProperty("success", false);
expect(response.body).toHaveProperty(
"error",
"Table not found or not currently reserved",
);
});
});
});

View File

@ -0,0 +1,171 @@
const mongoose = require("mongoose");
const request = require("supertest");
const { app } = require("../../app");
const Order = require("../../src/models/orderModel");
const Meja = require("../../src/models/mejaModel");
const Menu = require("../../src/models/menuModel");
const connectDB = require("../../src/config/database");
describe("Module 4 - Order API", () => {
let testMenuId1, testMenuId2;
beforeAll(async () => {
jest.spyOn(console, 'log').mockImplementation(() => { });
jest.spyOn(console, 'error').mockImplementation(() => { });
await connectDB();
const menu1 = await Menu.create({
name: "Nasi Goreng Spesial",
description: "Nasi goreng dengan telur, ayam, dan sayuran segar",
price: 25000,
category: "main",
isAvailable: true,
});
const menu2 = await Menu.create({
name: "Sate Ayam",
description: "Sate ayam dengan bumbu kacang khas Indonesia",
price: 20000,
category: "appetizer",
isAvailable: true,
});
testMenuId1 = menu1._id;
testMenuId2 = menu2._id;
});
beforeEach(async () => {
await Order.deleteMany({});
await Meja.deleteMany({});
});
afterAll(async () => {
await Menu.deleteMany({});
await Order.deleteMany({});
await Meja.deleteMany({});
await mongoose.connection.close();
});
describe("POST /createOrders", () => {
test("harus berhasil membuat pesanan baru", async () => {
const table = await Meja.create({
tableNumber: 3,
capacity: 4,
status: "available",
});
const orderData = {
tableNumber: 3,
items: [
{ menuId: testMenuId1, quantity: 1 },
{ menuId: testMenuId2, quantity: 2 },
],
};
const response = await request(app).post("/createOrders").send(orderData);
expect(response.status).toBe(201);
expect(response.body).toHaveProperty("success", true);
expect(response.body.data).toHaveProperty("tableNumber", 3);
expect(response.body.data).toHaveProperty("total", 65000);
expect(response.body.data).toHaveProperty("status", "pending");
const updatedTable = await Meja.findOne({ tableNumber: 3 });
expect(updatedTable.status).toBe("reserved");
});
test("harus mengembalikan error ketika meja tidak tersedia", async () => {
const table = await Meja.create({
tableNumber: 4,
capacity: 4,
status: "reserved",
});
const orderData = {
tableNumber: 4,
items: [{ menuId: testMenuId1, quantity: 1 }],
};
const response = await request(app).post("/createOrders").send(orderData);
console.log(response.status);
console.log(response.body);
expect(response.status).toBe(400);
expect(response.body).toHaveProperty("success", false);
expect(response.body).toHaveProperty(
"error",
"Meja tidak tersedia atau sedang dipesan",
);
});
});
describe("GET /orders", () => {
test("harus mengembalikan daftar pesanan", async () => {
await Meja.create({
tableNumber: 2,
capacity: 4,
status: "available",
});
const orderData = {
tableNumber: 2,
items: [
{ menuId: testMenuId1, quantity: 1 },
{ menuId: testMenuId2, quantity: 2 },
],
};
await request(app).post("/createOrders").send(orderData);
const response = await request(app).get("/orders");
expect(response.status).toBe(200);
expect(response.body).toHaveProperty("success", true);
expect(Array.isArray(response.body.data)).toBe(true);
expect(response.body.data.length).toBeGreaterThan(0);
expect(response.body.data[0]).toHaveProperty("tableNumber", 2);
});
});
describe("PUT /orders/:orderId/status", () => {
test("harus mengubah status pesanan", async () => {
const table = await Meja.create({
tableNumber: 5,
capacity: 4,
status: "reserved",
});
const order = await Order.create({
tableNumber: 5,
items: [
{ menuId: testMenuId1, quantity: 1 },
{ menuId: testMenuId2, quantity: 2 },
],
total: 65000,
status: "pending",
});
const response = await request(app)
.put(`/orders/${order._id}/status`)
.send({ status: "completed" });
expect(response.status).toBe(200);
expect(response.body).toHaveProperty("success", true);
expect(response.body.data).toHaveProperty("status", "completed");
});
test("harus mengembalikan status 404 jika pesanan tidak ditemukan", async () => {
const nonExistentOrderId = new mongoose.Types.ObjectId();
const response = await request(app)
.put(`/orders/${nonExistentOrderId}/status`)
.send({ status: "completed" });
expect(response.status).toBe(404);
expect(response.body).toHaveProperty("success", false);
expect(response.body).toHaveProperty("error", "Pesanan tidak ditemukan");
});
});
});

View File

@ -0,0 +1,97 @@
const mongoose = require("mongoose");
const request = require("supertest");
const { app } = require("../../app");
const Order = require("../../src/models/orderModel");
const Meja = require("../../src/models/mejaModel");
const Menu = require("../../src/models/menuModel");
const connectDB = require("../../src/config/database");
describe("Module 5 - Error Handling", () => {
let testMenuId;
beforeAll(async () => {
jest.spyOn(console, 'log').mockImplementation(() => { });
jest.spyOn(console, 'error').mockImplementation(() => { });
await connectDB();
// Create a test menu item
const menu = await Menu.create({
name: "Nasi Goreng",
description: "Nasi goreng spesial dengan telur dan ayam",
price: 25000,
category: "main",
isAvailable: true,
});
testMenuId = menu._id;
});
beforeEach(async () => {
// Clean collections before each test
await Order.deleteMany({});
await Meja.deleteMany({});
});
afterAll(async () => {
await Menu.deleteMany({});
await Order.deleteMany({});
await Meja.deleteMany({});
await mongoose.connection.close();
});
describe("POST /createOrders - Error handling", () => {
test("harus mengembalikan status 400 ketika meja tidak tersedia", async () => {
await Meja.create({
tableNumber: 7,
capacity: 4,
status: "reserved",
customerName: "Budi Santoso",
});
const orderData = {
tableNumber: 7,
items: [{ menuId: testMenuId, quantity: 2 }],
};
const response = await request(app).post("/createOrders").send(orderData);
expect(response.status).toBe(400);
expect(response.body).toHaveProperty("success", false);
expect(response.body).toHaveProperty(
"error",
"Meja tidak tersedia atau sedang dipesan",
);
});
test("harus mengembalikan status 400 ketika item menu tidak valid", async () => {
// Create available table
await Meja.create({
tableNumber: 8,
capacity: 4,
status: "available",
});
const invalidMenuId = new mongoose.Types.ObjectId();
const orderData = {
tableNumber: 8,
items: [{ menuId: invalidMenuId, quantity: 1 }],
};
const response = await request(app).post("/createOrders").send(orderData);
expect(response.status).toBe(400);
expect(response.body).toHaveProperty("success", false);
expect(response.body.error).toContain("Beberapa item menu tidak valid");
});
});
describe("Global error handling", () => {
test("harus menangani rute yang tidak ada dengan status 404", async () => {
const response = await request(app).get("/non-existent-endpoint");
expect(response.status).toBe(404);
});
});
});

View File

@ -0,0 +1,104 @@
const mongoose = require("mongoose");
const connectDB = require("../../src/config/database");
const errorHandler = require("../../src/middleware/errorHandler");
const mockRequest = (body = {}, params = {}) => ({
body,
params,
});
const mockResponse = () => {
const res = {};
res.status = jest.fn().mockReturnValue(res);
res.json = jest.fn().mockReturnValue(res);
return res;
};
describe("Modul 1 - Unit Tests", () => {
beforeEach(() => {
jest.spyOn(console, 'log').mockImplementation(() => { });
jest.clearAllMocks();
console.error = jest.fn();
});
describe("Error Handler Middleware", () => {
test("harus menangani error dengan status code yang disediakan", () => {
const err = new Error("Bad Request Error");
err.statusCode = 400;
const req = mockRequest();
const res = mockResponse();
const next = jest.fn();
errorHandler(err, req, res, next);
expect(console.error).toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: "Bad Request Error",
});
});
test("harus menggunakan default status code 500 jika tidak disediakan", () => {
const err = new Error("Server Error Without Status Code");
const req = mockRequest();
const res = mockResponse();
const next = jest.fn();
errorHandler(err, req, res, next);
expect(console.error).toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: "Server Error Without Status Code",
});
});
});
describe("Custom Error Creation", () => {
test("harus dapat membuat custom error dengan status code", () => {
const createCustomError = (message, statusCode) => {
const error = new Error(message);
error.statusCode = statusCode;
return error;
};
const notFoundError = createCustomError("Resource not found", 404);
const validationError = createCustomError("Validation failed", 400);
expect(notFoundError).toBeInstanceOf(Error);
expect(notFoundError.message).toBe("Resource not found");
expect(notFoundError.statusCode).toBe(404);
expect(validationError).toBeInstanceOf(Error);
expect(validationError.message).toBe("Validation failed");
expect(validationError.statusCode).toBe(400);
});
test("harus dapat meneruskan error ke middleware error handler", () => {
const err = new Error("Test Error");
err.statusCode = 422;
const req = mockRequest();
const res = mockResponse();
const next = jest.fn();
next(err);
errorHandler(err, req, res, next);
expect(next).toHaveBeenCalledWith(err);
expect(res.status).toHaveBeenCalledWith(422);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: "Test Error",
});
});
});
describe("Express Middleware Structure", () => {
test("harus memiliki struktur errorHandler yang benar", () => {
expect(errorHandler).toBeInstanceOf(Function);
expect(errorHandler.length).toBe(4);
});
});
});

View File

@ -0,0 +1,156 @@
const Menu = require("../../src/models/menuModel");
const menuController = require("../../src/controllers/menuController");
jest.mock("../../src/models/menuModel");
jest.mock("../../src/controllers/menuController", () => {
const originalModule = jest.requireActual(
"../../src/controllers/menuController",
);
return {
...originalModule,
withCallback: (promise, callback) => {
return promise
.then((data) => callback(null, data))
.catch((err) => callback(err));
},
};
});
describe("Modul 2 - Menu Controller", () => {
let req, res;
beforeEach(() => {
jest.spyOn(console, 'log').mockImplementation(() => { });
jest.clearAllMocks();
req = {
body: {
name: "Nasi Goreng",
description: "Nasi goreng dengan telur mata sapi",
price: 25000,
category: "main",
isAvailable: true,
},
params: {
category: "main",
},
};
res = {
status: jest.fn().mockReturnThis(),
json: jest.fn(),
};
});
describe("createMenuItem", () => {
test("harus berhasil membuat menu baru", (done) => {
const mockSavedMenu = { ...req.body, _id: "menu123" };
Menu.mockImplementation(() => ({
save: jest.fn().mockResolvedValue(mockSavedMenu),
}));
res.status = jest.fn().mockReturnThis();
res.json = jest.fn().mockImplementation(() => {
expect(res.status).toHaveBeenCalledWith(201);
expect(res.json).toHaveBeenCalledWith(mockSavedMenu);
done();
});
menuController.createMenuItem(req, res);
});
test("harus menangani error saat pembuatan menu", (done) => {
const errorMessage = "Validation error";
Menu.mockImplementation(() => ({
save: jest.fn().mockRejectedValue(new Error(errorMessage)),
}));
res.status = jest.fn().mockReturnThis();
res.json = jest.fn().mockImplementation(() => {
expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith({ error: errorMessage });
done();
});
menuController.createMenuItem(req, res);
});
});
describe("getAllMenuItems", () => {
test("harus mengembalikan semua menu", (done) => {
const mockItems = [
{ name: "Nasi Goreng", price: 25000, category: "main" },
{ name: "Sate Ayam", price: 20000, category: "appetizer" },
];
Menu.find.mockResolvedValue(mockItems);
res.json = jest.fn().mockImplementation((data) => {
expect(Menu.find).toHaveBeenCalledWith({});
expect(data).toEqual(mockItems);
done();
return res;
});
menuController.getAllMenuItems(req, res);
});
test("harus menangani error saat mengambil menu", (done) => {
const errorMessage = "Database error";
Menu.find.mockRejectedValue(new Error(errorMessage));
res.status = jest.fn().mockReturnThis();
res.json = jest.fn().mockImplementation(() => {
expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith({ error: errorMessage });
done();
return res;
});
menuController.getAllMenuItems(req, res);
});
});
describe("getMenuByCategory", () => {
test("harus mengembalikan menu berdasarkan kategori", (done) => {
const mockCategoryItems = [
{ name: "Nasi Goreng", price: 25000, category: "main" },
{ name: "Mie Goreng", price: 23000, category: "main" },
];
Menu.find.mockResolvedValue(mockCategoryItems);
res.json = jest.fn().mockImplementation((data) => {
expect(Menu.find).toHaveBeenCalledWith({ category: "main" });
expect(data).toEqual(mockCategoryItems);
done();
return res;
});
menuController.getMenuByCategory(req, res);
});
test("harus mengembalikan 404 ketika kategori tidak ditemukan", (done) => {
Menu.find.mockResolvedValue([]);
res.status = jest.fn().mockReturnThis();
res.json = jest.fn().mockImplementation(() => {
expect(Menu.find).toHaveBeenCalledWith({ category: "main" });
expect(res.status).toHaveBeenCalledWith(404);
expect(res.json).toHaveBeenCalledWith({
error: "Menu with category 'main' not found",
});
done();
return res;
});
menuController.getMenuByCategory(req, res);
});
});
});

View File

@ -0,0 +1,256 @@
const Meja = require("../../src/models/mejaModel");
const mejaController = require("../../src/controllers/mejaController");
const mockRequest = (body = {}, params = {}) => ({
body,
params,
});
const mockResponse = () => {
const res = {};
res.status = jest.fn().mockReturnValue(res);
res.json = jest.fn().mockReturnValue(res);
return res;
};
jest.mock("../../src/models/mejaModel");
describe("Modul 3 - Meja Controller", () => {
afterEach(() => {
jest.spyOn(console, 'log').mockImplementation(() => { });
jest.clearAllMocks();
});
describe("createMeja", () => {
test("harus berhasil membuat meja baru", async () => {
const mockMeja = {
tableNumber: 5,
capacity: 4,
status: "available",
};
Meja.create.mockResolvedValue(mockMeja);
const req = mockRequest({ tableNumber: 5, capacity: 4 });
const res = mockResponse();
await mejaController.createMeja(req, res);
expect(Meja.create).toHaveBeenCalledWith({
tableNumber: 5,
capacity: 4,
});
expect(res.status).toHaveBeenCalledWith(201);
expect(res.json).toHaveBeenCalledWith({
success: true,
data: mockMeja,
});
});
test("harus mengembalikan error 400 ketika validasi gagal", async () => {
const errorMessage = "Validation error";
Meja.create.mockRejectedValue(new Error(errorMessage));
const req = mockRequest({ tableNumber: 5 });
const res = mockResponse();
await mejaController.createMeja(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: errorMessage,
});
});
});
describe("getAllMeja", () => {
test("harus mengembalikan semua data meja", async () => {
const mockMejaList = [
{ tableNumber: 1, capacity: 2, status: "available" },
{ tableNumber: 2, capacity: 4, status: "reserved" },
];
const mockSort = jest.fn().mockResolvedValue(mockMejaList);
const mockThen = jest.fn().mockImplementation(callback => Promise.resolve(callback(mockMejaList)));
const mockFindResult = { sort: mockSort, then: mockThen };
Meja.find = jest.fn().mockReturnValue(mockFindResult);
const req = mockRequest();
const res = mockResponse();
await mejaController.getAllMeja(req, res);
expect(Meja.find).toHaveBeenCalled();
// Don't test for sort being called specifically, as it may not be in all implementations
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith({
success: true,
data: mockMejaList,
});
});
test("harus mengembalikan error 400 ketika terjadi kesalahan database", async () => {
const errorMessage = "Database error";
// Create a more flexible mock that handles both implementations
const mockFindResult = {};
// For implementations with sort
const mockSort = jest.fn().mockRejectedValue(new Error(errorMessage));
mockFindResult.sort = mockSort;
// For implementations without sort
mockFindResult.then = jest.fn().mockImplementation(() =>
Promise.reject(new Error(errorMessage))
);
Meja.find = jest.fn().mockReturnValue(mockFindResult);
const req = mockRequest();
const res = mockResponse();
await mejaController.getAllMeja(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: errorMessage,
});
});
});
describe("reserveMeja", () => {
test("harus berhasil mereservasi meja", async () => {
const tableNumber = "10";
const customerName = "Susilo Bambang";
const mockMeja = {
tableNumber: 10,
capacity: 4,
status: "reserved",
customerName: "Susilo Bambang",
};
Meja.findOneAndUpdate.mockResolvedValue(mockMeja);
const req = mockRequest({ customerName }, { tableNumber });
const res = mockResponse();
await mejaController.reserveMeja(req, res);
expect(Meja.findOneAndUpdate).toHaveBeenCalledWith(
{ tableNumber, status: "available" },
{ status: "reserved", customerName },
{ new: true },
);
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith({
success: true,
data: mockMeja,
});
});
test("harus mengembalikan error 400 ketika nama pelanggan tidak disediakan", async () => {
const tableNumber = "10";
const req = mockRequest({}, { tableNumber });
const res = mockResponse();
await mejaController.reserveMeja(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: "Nama pelanggan harus diisi",
});
});
test("harus mengembalikan error 404 ketika meja tidak tersedia", async () => {
const tableNumber = "10";
const customerName = "Susilo Bambang";
Meja.findOneAndUpdate.mockResolvedValue(null);
const req = mockRequest({ customerName }, { tableNumber });
const res = mockResponse();
await mejaController.reserveMeja(req, res);
expect(res.status).toHaveBeenCalledWith(404);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: "Meja tidak tersedia",
});
});
});
describe("cancelReservation", () => {
test("harus berhasil membatalkan reservasi meja", async () => {
const tableNumber = "15";
const mockMeja = {
tableNumber: 15,
capacity: 4,
status: "available",
customerName: "",
};
Meja.findOneAndUpdate.mockResolvedValue(mockMeja);
const req = mockRequest({}, { tableNumber });
const res = mockResponse();
await mejaController.cancelReservation(req, res);
expect(Meja.findOneAndUpdate).toHaveBeenCalledWith(
{ tableNumber, status: "reserved" },
{
status: "available",
customerName: "",
updatedAt: expect.any(Number),
},
{ new: true },
);
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith({
success: true,
message: `Reservation for table ${tableNumber} has been cancelled`,
data: mockMeja,
});
});
test("harus mengembalikan error 404 ketika meja tidak ditemukan", async () => {
const tableNumber = "99";
Meja.findOneAndUpdate.mockResolvedValue(null);
const req = mockRequest({}, { tableNumber });
const res = mockResponse();
await mejaController.cancelReservation(req, res);
expect(res.status).toHaveBeenCalledWith(404);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: "Table not found or not currently reserved",
});
});
test("harus mengembalikan error 400 ketika terjadi kesalahan database", async () => {
const tableNumber = "15";
const errorMessage = "Database error";
Meja.findOneAndUpdate.mockRejectedValue(new Error(errorMessage));
const req = mockRequest({}, { tableNumber });
const res = mockResponse();
await mejaController.cancelReservation(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: errorMessage,
});
});
});
});

View File

@ -0,0 +1,227 @@
const mongoose = require("mongoose");
const Order = require("../../src/models/orderModel");
const Meja = require("../../src/models/mejaModel");
const Menu = require("../../src/models/menuModel");
const orderController = require("../../src/controllers/orderController");
const mockRequest = (body = {}, params = {}) => ({
body,
params,
});
const mockResponse = () => {
const res = {};
res.status = jest.fn().mockReturnValue(res);
res.json = jest.fn().mockReturnValue(res);
return res;
};
jest.mock("../../src/models/orderModel");
jest.mock("../../src/models/mejaModel");
jest.mock("../../src/models/menuModel");
const mockObjectId = new mongoose.Types.ObjectId();
describe("Modul 4 - Order Controller", () => {
let req, res, next;
beforeEach(() => {
jest.spyOn(console, 'log').mockImplementation(() => { });
jest.clearAllMocks();
req = {
body: {
tableNumber: 3,
items: [
{ menuId: mockObjectId.toString(), quantity: 1 },
{ menuId: mockObjectId.toString(), quantity: 2 },
],
},
params: {
orderId: mockObjectId.toString(),
},
};
res = mockResponse();
next = jest.fn();
});
describe("createOrder", () => {
test("harus berhasil membuat pesanan baru", async () => {
const mockMeja = {
tableNumber: 3,
status: "available",
};
const mockMenuItems = [
{ _id: mockObjectId, price: 25000 },
{ _id: mockObjectId, price: 20000 },
];
const mockSavedOrder = {
_id: mockObjectId,
tableNumber: 3,
items: req.body.items,
total: 65000,
status: "pending",
};
Meja.findOne.mockResolvedValue(mockMeja);
Menu.find.mockResolvedValue(mockMenuItems);
Order.prototype.save = jest.fn().mockResolvedValue(mockSavedOrder);
Meja.findOneAndUpdate.mockResolvedValue({ status: "reserved" });
await orderController.createOrder(req, res, next);
expect(Meja.findOne).toHaveBeenCalledWith({
tableNumber: 3,
status: "available",
});
expect(Menu.find).toHaveBeenCalled();
expect(Order.prototype.save).toHaveBeenCalled();
expect(Meja.findOneAndUpdate).toHaveBeenCalledWith(
{ tableNumber: 3 },
{ status: "reserved" },
);
expect(res.status).toHaveBeenCalledWith(201);
expect(res.json).toHaveBeenCalledWith({
success: true,
data: mockSavedOrder,
});
});
test("harus menangani error ketika meja tidak tersedia", async () => {
Meja.findOne.mockResolvedValue(null);
await orderController.createOrder(req, res, next);
expect(Meja.findOne).toHaveBeenCalledWith({
tableNumber: 3,
status: "available",
});
});
test("harus menangani error ketika item menu tidak valid", async () => {
Meja.findOne.mockResolvedValue({ tableNumber: 3, status: "available" });
Menu.find.mockResolvedValue([]);
await orderController.createOrder(req, res, next);
expect(Menu.find).toHaveBeenCalled();
});
});
describe("getAllOrders", () => {
test("harus mengembalikan semua pesanan", async () => {
const mockOrders = [
{ _id: mockObjectId, tableNumber: 1, total: 50000, status: "pending" },
{
_id: mockObjectId,
tableNumber: 2,
total: 75000,
status: "completed",
},
];
const mockSort = jest.fn().mockResolvedValue(mockOrders);
Order.find = jest.fn().mockReturnValue({ sort: mockSort });
await orderController.getAllOrders(req, res);
expect(Order.find).toHaveBeenCalled();
expect(mockSort).toHaveBeenCalledWith({ createdAt: -1 });
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith({
success: true,
data: mockOrders,
});
});
test("harus menangani error saat mengambil pesanan", async () => {
const errorMessage = "Database error";
Order.find = jest.fn().mockImplementation(() => {
throw new Error(errorMessage);
});
await orderController.getAllOrders(req, res);
expect(Order.find).toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: errorMessage,
});
});
});
describe("updateOrderStatus", () => {
test("harus berhasil mengupdate status pesanan", async () => {
req.body = { status: "completed" };
const mockUpdatedOrder = {
_id: mockObjectId,
tableNumber: 3,
status: "completed",
};
Order.findByIdAndUpdate.mockResolvedValue(mockUpdatedOrder);
Meja.findOneAndUpdate.mockResolvedValue({ status: "available" });
await orderController.updateOrderStatus(req, res);
expect(Order.findByIdAndUpdate).toHaveBeenCalledWith(
mockObjectId.toString(),
{ status: "completed" },
{ new: true },
);
expect(Meja.findOneAndUpdate).toHaveBeenCalledWith(
{ tableNumber: 3 },
{ status: "available" },
);
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith({
success: true,
data: mockUpdatedOrder,
});
});
test("harus mengembalikan 404 ketika pesanan tidak ditemukan", async () => {
req.body = { status: "completed" };
Order.findByIdAndUpdate.mockResolvedValue(null);
await orderController.updateOrderStatus(req, res);
expect(Order.findByIdAndUpdate).toHaveBeenCalledWith(
mockObjectId.toString(),
{ status: "completed" },
{ new: true },
);
expect(res.status).toHaveBeenCalledWith(404);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: "Pesanan tidak ditemukan",
});
});
test("harus menangani error saat mengupdate status", async () => {
req.body = { status: "completed" };
const errorMessage = "Database error";
Order.findByIdAndUpdate.mockRejectedValue(new Error(errorMessage));
await orderController.updateOrderStatus(req, res);
expect(Order.findByIdAndUpdate).toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: errorMessage,
});
});
});
});

View File

@ -0,0 +1,143 @@
const errorHandler = require("../../src/middleware/errorHandler");
const mongoose = require("mongoose");
const Order = require("../../src/models/orderModel");
const Meja = require("../../src/models/mejaModel");
const Menu = require("../../src/models/menuModel");
const orderController = require("../../src/controllers/orderController");
const mockRequest = (body = {}, params = {}) => ({
body,
params,
});
const mockResponse = () => {
const res = {};
res.status = jest.fn().mockReturnValue(res);
res.json = jest.fn().mockReturnValue(res);
return res;
};
jest.mock("../../src/models/orderModel");
jest.mock("../../src/models/mejaModel");
jest.mock("../../src/models/menuModel");
const mockObjectId = new mongoose.Types.ObjectId();
describe("Modul 5 - Error Handling", () => {
let req, res, next;
beforeEach(() => {
jest.spyOn(console, 'log').mockImplementation(() => { });
jest.clearAllMocks();
console.error = jest.fn();
req = {
body: {
tableNumber: 7,
items: [{ menuId: mockObjectId.toString(), quantity: 2 }],
},
params: {},
};
res = mockResponse();
next = jest.fn();
});
describe("Error Handler Middleware", () => {
test("harus menangani error dengan status code dari error", () => {
const error = new Error("Validation error");
error.statusCode = 400;
errorHandler(error, req, res, next);
expect(console.error).toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: "Validation error",
});
});
test("harus menangani error tanpa status code dengan default 500", () => {
const error = new Error("Server error");
errorHandler(error, req, res, next);
expect(console.error).toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: "Server error",
});
});
});
describe("Order Controller Error Handling", () => {
test("harus menangani error meja tidak tersedia dengan status 400", async () => {
Meja.findOne.mockResolvedValue(null);
await orderController.createOrder(req, res, next);
expect(next).toHaveBeenCalledWith(expect.any(Error));
expect(next.mock.calls[0][0].message).toBe(
"Meja tidak tersedia atau sedang dipesan",
);
expect(next.mock.calls[0][0].statusCode).toBe(400);
});
test("harus menangani error menu tidak valid dengan status 400", async () => {
Meja.findOne.mockResolvedValue({ tableNumber: 7, status: "available" });
Menu.find.mockResolvedValue([]);
await orderController.createOrder(req, res, next);
expect(next).toHaveBeenCalledWith(expect.any(Error));
expect(next.mock.calls[0][0].message).toBe(
"Beberapa item menu tidak valid",
);
expect(next.mock.calls[0][0].statusCode).toBe(400);
});
test("harus menangani exception dengan middleware error", async () => {
const errorMessage = "Database connection failed";
Meja.findOne.mockImplementation(() => {
throw new Error(errorMessage);
});
await orderController.createOrder(req, res, next);
expect(next).toHaveBeenCalledWith(expect.any(Error));
expect(next.mock.calls[0][0].message).toBe(errorMessage);
});
test("harus mengubah error menjadi format error response", () => {
const error = new Error("Custom error");
error.statusCode = 422;
errorHandler(error, req, res, next);
expect(res.status).toHaveBeenCalledWith(422);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: "Custom error",
});
});
});
describe("Custom Error Creation", () => {
test("harus membuat error dengan statusCode yang tepat", async () => {
const createError = (message, statusCode) => {
const error = new Error(message);
error.statusCode = statusCode;
return error;
};
const error = createError("Resource not found", 404);
expect(error).toBeInstanceOf(Error);
expect(error.message).toBe("Resource not found");
expect(error.statusCode).toBe(404);
});
});
});

View File

@ -45,7 +45,7 @@
<img src="{{ asset('./images/Group.png') }}" alt="Group" style="height: 50px; margin-right: 10px;">
<i class="fas fa-chevron-down" style="color: #0079FF;"></i>
<div class="dropdown-content" id="dropdownContent">
<form id="logout-form" action="{{ route('logoutt') }}" method="POST">
<form id="logout-form" action="{{ route('logout') }}" method="GET">
@csrf
<a href="/" onclick="event.preventDefault(); document.getElementById('logout-form').submit();">Logout</a>
</form>
@ -106,7 +106,7 @@
<div class="card p-0" style="width: 305px; height:375px; margin-left: 25px; box-shadow: 0px 0px 10px rgba(0, 0, 0, 0.1);">
<img src={{asset("./images/cards/Node.js.png")}} class="card-img-top" style="width: auto; height: 200px;">
<div class="card-body d-flex flex-column">
<h5 class="card-title">Web application with Node.JS</h5>
<h5 class="card-title">Web Application With NodeJS</h5>
<div class="row align-items-start">
<div class="col-1">
<img src={{asset("./images/book.png ")}} style="width: 13px; height: 16px;">
@ -116,7 +116,7 @@
</div>
</div>
<div style="margin-top: auto;">
<a href="/nodejs" class="btn btn-primary">Start Learning</a>
<a href="/nodejs/dashboard" class="btn btn-primary">Start Learning</a>
</div>
</div>
</div>

View File

@ -0,0 +1,413 @@
<!doctype html>
<html lang="en">
<head>
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-9ndCyUaIbzAi2FUVXJi0CjmCapSmO7SnpJef0486qhLnuZ2cdeRhO02iuK6FUUVM" crossorigin="anonymous">
<link href="style.css" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.3/css/all.min.css">
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.16.0/umd/popper.min.js"></script>
<title>iCLOP</title>
<link rel="icon" href={{asset("./images/logo.png")}} type="image/png">
</head>
<body>
<!-- NAVBAR -->
<nav class="navbar navbar-expand-lg" style="background-color: #FEFEFE;">
<div class="container-fluid">
<!-- <a class="navbar-brand" href="#">Navbar</a> -->
<img src={{asset("./images/logo.png")}} alt="logo" width="104" height="65">
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<div class="mx-auto">
<ul class="navbar-nav mb-2 mb-lg-0 justify-content-center">
<li class="nav-item">
<a class="nav-link active" aria-current="page" href="#">Dashboard Teacher</a>
</li>
<li class="nav-item">
<a class="nav-link active" aria-current="page" href="#">Tutorials</a>
</li>
<li class="nav-item">
<a class="nav-link active" aria-current="page" href="#">Contact Us</a>
</li>
</ul>
</div>
<div class="dropdown">
<p style="margin-top: 10px; margin-right: 10px;">{{auth()->user()->name}}
<img src="{{ asset('./images/Group.png') }}" alt="Group" style="height: 50px; margin-right: 10px;">
<i class="fas fa-chevron-down" style="color: #0079FF;"></i>
<div class="dropdown-content" id="dropdownContent">
<form id="logout-form" action="{{ route('logout') }}" method="GET">
@csrf
<a href="#" onclick="event.preventDefault(); document.getElementById('logout-form').submit();">Logout</a>
</form>
</div>
</p>
</div>
<!-- <button class="btn btn-primary custom-button-sign-up" onclick="window.location.href='register.html'">Sign Up</button> -->
</div>
</div>
</nav>
@auth
<!-- CONTENT -->
<div class="container" style="margin-top: 70px; justify-content: center; align-items: center;">
<p style="font-size: 22px;">Choose your<br><span style="font-size: 35px; font-weight: 600; color: #34364A;">Learning Materials</span></p>
<!-- CARD 1 -->
<!-- <div class="row" style="margin-top: 45px; display: flex; justify-content: center; align-items: center;"> -->
<div class="row" style="margin-top: 45px;">
<div class="card p-0" style="width: 305px; height: 375px; margin-left: 25px; box-shadow: 0px 0px 10px rgba(0, 0, 0, 0.1);">
<img src={{asset("./images/cards/Android.png")}} class="card-img-top" style="width: auto; height: 200px;">
<div class="card-body d-flex flex-column">
<h5 class="card-title">Android programming
with Java and Kotlin</h5>
<div class="row align-items-start">
<div class="col-1">
<img src={{asset("./images/book.png")}} style="width: 13px; height: 16px;">
</div>
<div class="col">
<p>18 learning topics</p>
</div>
</div>
<div style="margin-top: auto;">
<a href="/android23/topic" class="btn btn-primary">Start Learning</a>
</div>
</div>
</div>
<div class="card p-0" style="width: 305px; height: 375px; margin-left: 25px; box-shadow: 0px 0px 10px rgba(0, 0, 0, 0.1);">
<img src={{asset("./images/cards/Flutter.png")}} class="card-img-top" style="width: auto; height: 200px;">
<div class="card-body d-flex flex-column">
<h5 class="card-title">Mobile programming with Flutter</h5>
<div class="row align-items-start">
<div class="col-1">
<img src={{asset("./images/book.png")}} style="width: 13px; height: 16px;">
</div>
<div class="col">
<p>18 learning topics</p>
</div>
</div>
<div style="margin-top: auto;">
<a href="/flutter/start" class="btn btn-primary">Start Learning</a>
</div>
</div>
</div>
<div class="card p-0" style="width: 305px; height:375px; margin-left: 25px; box-shadow: 0px 0px 10px rgba(0, 0, 0, 0.1);">
<img src={{asset("./images/cards/Node.js.png")}} class="card-img-top" style="width: auto; height: 200px;">
<div class="card-body d-flex flex-column">
<h5 class="card-title">Web application with Node.JS</h5>
<div class="row align-items-start">
<div class="col-1">
<img src={{asset("./images/book.png ")}} style="width: 13px; height: 16px;">
</div>
<div class="col">
<p>{{ $topicCount ?? '0' }} learning topics</p>
</div>
</div>
<div style="margin-top: auto;">
<a href="{{ route('dashboard.nodejs.teacher') }}" class="btn btn-primary">Start Learning</a>
</div>
</div>
</div>
</div>
<!-- ---------------------------------------------------------------------------------------------------- -->
<!-- CARD 2 -->
<!-- <div class="row" style="margin-top: 45px; display: flex; justify-content: center; align-items: center;"> -->
<div class="row" style="margin-top: 45px;">
<div class="card p-0" style="width: 305px; height: 375px; margin-left: 25px; box-shadow: 0px 0px 10px rgba(0, 0, 0, 0.1);">
<img src="{{asset("./images/cards/Python.png")}}" class="card-img-top" style="width: auto; height: 200px;">
<div class="card-body d-flex flex-column">
<h5 class="card-title">Python programming</h5>
<div class="row align-items-start">
<div class="col-1">
<img src="{{asset("./images/book.png ")}}" style="width: 13px; height: 16px;">
</div>
<div class="col">
<p>18 learning topics</p>
</div>
</div>
<div style="margin-top: auto;">
<a href="{{ route('learning_student') }}" class="btn btn-primary">Start Learning</a>
</div>
</div>
</div>
<div class="card p-0" style="width: 305px; height: 375px; margin-left: 25px; box-shadow: 0px 0px 10px rgba(0, 0, 0, 0.1);">
<img src="{{asset("./images/cards/MySQL.png")}}" class="card-img-top" style="width: auto; height: 200px;">
<div class="card-body d-flex flex-column">
<h5 class="card-title">SQL Querying with MySQL</h5>
<div class="row align-items-start">
<div class="col-1">
<img src="{{asset("./images/book.png ")}}" style="width: 13px; height: 16px;">
</div>
<div class="col">
<p>18 learning topics</p>
</div>
</div>
<div style="margin-top: auto;">
<a href="{{ route('learning_student') }}" class="btn btn-primary">Start Learning</a>
</div>
</div>
</div>
<div class="card p-0" style="width: 305px; height: 375px; margin-left: 25px; box-shadow: 0px 0px 10px rgba(0, 0, 0, 0.1);">
<img src="{{asset("./images/cards/PostgreSQL.png")}}" class="card-img-top" style="width: auto; height: 200px;">
<div class="card-body d-flex flex-column">
<h5 class="card-title">SQL Querying with PostgreSQL</h5>
<div class="row align-items-start">
<div class="col-1">
<img src="{{asset("./images/book.png ")}}" style="width: 13px; height: 16px;">
</div>
<div class="col">
<p>18 learning topics</p>
</div>
</div>
<div style="margin-top: auto;">
<a href="{{ route('learning_student') }}" class="btn btn-primary">Start Learning</a>
</div>
</div>
</div>
</div>
<!-- ---------------------------------------------------------------------------------------------------- -->
<!-- CARD 3 -->
<!-- <div class="row" style="margin-top: 45px; display: flex; justify-content: center; align-items: center;"> -->
<div class="row" style="margin-top: 45px;">
<div class="card p-0" style="width: 305px; height: 375px; margin-left: 25px; box-shadow: 0px 0px 10px rgba(0, 0, 0, 0.1);">
<img src="{{asset("./images/cards/Network.png ")}}" class="card-img-top" style="width: auto; height: 200px;">
<div class="card-body d-flex flex-column">
<h5 class="card-title">Network programming with Java</h5>
<div class="row align-items-start">
<div class="col-1">
<img src="{{asset("./images/book.png ")}}" style="width: 13px; height: 16px;">
</div>
<div class="col">
<p>18 learning topics</p>
</div>
</div>
<div style="margin-top: auto;">
<a href="{{ route('learning_student') }}" class="btn btn-primary">Start Learning</a>
</div>
</div>
</div>
<div class="card p-0" style="width: 305px; height: 375px; margin-left: 25px; box-shadow: 0px 0px 10px rgba(0, 0, 0, 0.1);">
<img src="{{asset("./images/cards/Unity.png")}}" class="card-img-top" style="width: auto; height: 200px;">
<div class="card-body d-flex flex-column">
<h5 class="card-title">Game programming with Unity</h5>
<div class="row align-items-start">
<div class="col-1">
<img src="{{asset("./images/book.png ")}}" style="width: 13px; height: 16px;">
</div>
<div class="col">
<p>18 learning topics</p>
</div>
</div>
<div style="margin-top: auto;">
<a href="{{ route('learning_student') }}" class="btn btn-primary">Start Learning</a>
</div>
</div>
</div>
<div class="card p-0" style="width: 305px; height: 375px; margin-left: 25px; box-shadow: 0px 0px 10px rgba(0, 0, 0, 0.1);">
<img src="{{asset("./images/cards/Data analytic.png ")}}" class="card-img-top" style="width: auto; height: 200px;">
<div class="card-body d-flex flex-column">
<h5 class="card-title">Data Analytics with Python</h5>
<div class="row align-items-start">
<div class="col-1">
<img src="{{asset("./images/book.png ")}}" style="width: 13px; height: 16px;">
</div>
<div class="col">
<p>18 learning topics</p>
</div>
</div>
<div style="margin-top: auto;">
<a href="{{ route('learning_student') }}" class="btn btn-primary">Start Learning</a>
</div>
</div>
</div>
</div>
<div class="row" style="margin-top: 45px;">
<div class="card p-0" style="width: 305px; height: 375px; margin-left: 25px; box-shadow: 0px 0px 10px rgba(0, 0, 0, 0.1);">
<img src="{{asset("./images/cards/DB.png ")}}" class="card-img-top" style="width: auto; height: 200px;">
<div class="card-body d-flex flex-column">
<h5 class="card-title">Database with PHP Programming</h5>
<div class="row align-items-start">
<div class="col-1">
<img src="{{asset("./images/book.png ")}}" style="width: 13px; height: 16px;">
</div>
<div class="col">
<p>18 learning topics</p>
</div>
</div>
<div style="margin-top: auto;">
<a href="{{ route('welcome') }}" class="btn btn-primary">Start Learning</a>
</div>
</div>
</div>
<div class="card p-0" style="width: 305px; height: 375px; margin-left: 25px; box-shadow: 0px 0px 10px rgba(0, 0, 0, 0.1);">
<img src="{{asset("./images/cards/React.jpg")}}" class="card-img-top" style="width: auto; height: 200px;">
<div class="card-body d-flex flex-column">
<h5 class="card-title">Learn React JS</h5>
<div class="row align-items-start">
<div class="col-1">
<img src="{{asset("./images/book.png ")}}" style="width: 13px; height: 16px;">
</div>
<div class="col">
<p>6 learning topics</p>
</div>
</div>
<div style="margin-top: auto;">
<a href="{{ route('react_welcome') }}" class="btn btn-primary">Start Learning</a>
</div>
</div>
</div>
<!-- <div class="card p-0" style="width: 305px; height: 375px; margin-left: 25px; box-shadow: 0px 0px 10px rgba(0, 0, 0, 0.1);">
<img src="{{asset("./images/cards/DB.png ")}}" class="card-img-top" style="width: auto; height: 200px;">
<div class="card-body d-flex flex-column">
<h5 class="card-title">Database Programming with PHP</h5>
<div class="row align-items-start">
<div class="col-1">
<img src="{{asset("./images/book.png ")}}" style="width: 13px; height: 16px;">
</div>
<div class="col">
<p>18 learning topics</p>
</div>
</div>
<div style="margin-top: auto;">
<a href="{{ route('welcome') }}" class="btn btn-primary">Start Learning</a>
</div>
</div>
</div> -->
</div>
</div>
<!-- FOOTER -->
<div class="container text-align" style="margin-top: 100px; background-color: #FAFAFA; padding-right: 50px; padding-left: 50px; height: 300px;">
<div class="row">
<div class="col">
<div class="container" style="margin-top: 40px;">
<img src="{{asset("./images/logo.png ")}}" alt="rocket" style="height: 60px;">
<p style="font-size: 16px; color: #636363;">Intelligent Computer Assisted<br>Programming Learning Platform.</p>
</div>
<div class="container">
<i class="fab fa-instagram fa-lg" style="padding-right: 2px; color: #636363;"></i>
<i class="fab fa-github fa-lg" style="padding-right: 2px; color: #636363;"></i>
<i class="fab fa-linkedin fa-lg" style="padding-right: 2px; color: #636363;"></i>
<i class="fab fa-youtube fa-lg" style="padding-right: 2px; color: #636363;"></i>
</div>
</div>
<div class="col">
<div class="container">
<p style="font-size: 22px; font-weight: 600; color: #34364A; margin-top: 40px; margin-left: 55px;">Company</p>
<p style="font-size: 16px; color: #636363; margin-left: 55px;">Privacy Policy</p>
</div>
</div>
<div class="col">
<div class="container">
<p style="font-size: 22px; font-weight: 600; color: #34364A; margin-top: 40px;">Contact Info</p>
<div class="row align-items-start">
<div class="col-1">
<i class="fas fa-map-marker-alt fa-lg" style="color: #636363; margin-top: 5px;"></i>
</div>
<div class="col">
<p style="font-size: 16px; color: #636363;">Jl. Candi Mendut, RT.02/RW.08, Mojolangu, Kec. Lowokwaru, Kota Malang, Jawa Timur 65142</p>
</div>
</div>
<div class="row align-items-start">
<div class="col-1">
<i class="fas fa-envelope" style="color: #636363; margin-top: 5px;"></i>
</div>
<div class="col">
<p style="font-size: 16px; color: #636363;">qulispolinema.ac.id (Email)</p>
</div>
</div>
</div>
</div>
<div class="divider"></div>
<p style="font-size: 16px; color: #636363; text-align: center; margin-top: 16px; margin-bottom: 16px;">© 2023 iCLOP. All rights reserved</p>
</div>
</div>
</div>
@endauth
</body>
<script src="script.js"></script>
<script src="https://code.jquery.com/jquery-3.6.4.min.js"></script>
<script>
$(document).ready(function() {
$("#dropdownContainer").click(function() {
$("#dropdownContainer").toggleClass("active");
});
$("#dropdownContent").click(function(e) {
e.stopPropagation();
});
$(document).click(function() {
$("#dropdownContainer").removeClass("active");
});
});
</script>
<style>
.dropdown {
position: relative;
display: inline-block;
cursor: pointer;
}
.dropdown-content {
display: none;
position: absolute;
background-color: #fff;
min-width: 160px;
box-shadow: 0px 8px 16px 0px rgba(0, 0, 0, 0.2);
z-index: 1;
border-radius: 5px;
overflow: hidden;
transition: 0.3s;
opacity: 0;
transform: translateY(-10px);
}
.dropdown-content a {
color: black;
padding: 12px 16px;
text-decoration: none;
display: block;
transition: 0.3s;
}
.dropdown-content a:hover {
background-color: #f1f1f1;
}
.dropdown:hover .dropdown-content {
display: block;
opacity: 1;
transform: translateY(0);
}
.dropdown.active .dropdown-content {
display: block;
opacity: 1;
transform: translateY(0);
}
</style>
</html>

View File

@ -0,0 +1,341 @@
<x-app-layout>
<x-slot name="header">
<h2 class="font-semibold text-xl text-gray-800 dark:text-gray-200 leading-tight">
{{ __('Teacher Dashboard') }}
</h2>
</x-slot>
<div class="py-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8 pb-12">
<!-- Summary Cards -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-16">
<!-- Total Projects -->
<div class="bg-white dark:bg-gray-800 overflow-hidden shadow-sm sm:rounded-lg">
<div class="p-6 text-gray-900 dark:text-gray-100">
<div class="flex items-center">
<div class="p-3 rounded-full bg-indigo-500 bg-opacity-75">
<i class="fas fa-project-diagram text-white text-2xl"></i>
</div>
<div class="ml-4">
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Total Projects</p>
<p class="text-lg font-semibold">{{ $totalProyek }}</p>
</div>
</div>
</div>
</div>
<!-- Total Students -->
<div class="bg-white dark:bg-gray-800 overflow-hidden shadow-sm sm:rounded-lg">
<div class="p-6 text-gray-900 dark:text-gray-100">
<div class="flex items-center">
<div class="p-3 rounded-full bg-blue-500 bg-opacity-75">
<i class="fas fa-users text-white text-2xl"></i>
</div>
<div class="ml-4">
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Total Students</p>
<p class="text-lg font-semibold">{{ $totalSiswa }}</p>
</div>
</div>
</div>
</div>
<!-- Total Submissions -->
<div class="bg-white dark:bg-gray-800 overflow-hidden shadow-sm sm:rounded-lg">
<div class="p-6 text-gray-900 dark:text-gray-100">
<div class="flex items-center">
<div class="p-3 rounded-full bg-green-500 bg-opacity-75">
<i class="fas fa-file-code text-white text-2xl"></i>
</div>
<div class="ml-4">
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Total Submissions</p>
<p class="text-lg font-semibold">{{ $totalSubmisi }}</p>
</div>
</div>
</div>
</div>
<!-- Completed Submissions -->
<div class="bg-white dark:bg-gray-800 overflow-hidden shadow-sm sm:rounded-lg">
<div class="p-6 text-gray-900 dark:text-gray-100">
<div class="flex items-center">
<div class="p-3 rounded-full bg-purple-500 bg-opacity-75">
<i class="fas fa-check-circle text-white text-2xl"></i>
</div>
<div class="ml-4">
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Completed Submissions</p>
<p class="text-lg font-semibold">{{ $submissionSelesai }}</p>
</div>
</div>
</div>
</div>
<!-- Failed Submissions -->
<div class="bg-white dark:bg-gray-800 overflow-hidden shadow-sm sm:rounded-lg">
<div class="p-6 text-gray-900 dark:text-gray-100">
<div class="flex items-center">
<div class="p-3 rounded-full bg-purple-500 bg-opacity-75">
<i class="fas fa-times-circle text-white text-2xl"></i>
</div>
<div class="ml-4">
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Failed Submissions</p>
<p class="text-lg font-semibold">{{ $submissionGagal }}</p>
</div>
</div>
</div>
</div>
<!-- Average Submissions -->
<div class="bg-white dark:bg-gray-800 overflow-hidden shadow-sm sm:rounded-lg">
<div class="p-6 text-gray-900 dark:text-gray-100">
<div class="flex items-center">
<div class="p-3 rounded-full bg-yellow-500 bg-opacity-75">
<i class="fas fa-calculator text-white text-2xl"></i>
</div>
<div class="ml-4">
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Average Submissions Attempts</p>
<p class="text-lg font-semibold">{{ $ratarataPecobaan }}</p>
</div>
</div>
</div>
</div>
</div>
<!-- Charts Row -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-8 mt-4">
<!-- Submission Status Chart -->
<div class="bg-white dark:bg-gray-800 overflow-hidden shadow-sm sm:rounded-lg">
<div class="p-6">
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">Submission Status</h3>
<div class="relative h-64">
<canvas id="submissionStatusChart"></canvas>
</div>
</div>
</div>
<!-- Project Distribution Chart -->
<div class="bg-white dark:bg-gray-800 overflow-hidden shadow-sm sm:rounded-lg">
<div class="p-6">
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">Projects with vs without Submissions</h3>
<div class="relative h-64">
<canvas id="projectDistributionChart"></canvas>
</div>
</div>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-2 gap-4 mb-8 mt-4">
<!-- Student Participation Rate -->
<div class="bg-white dark:bg-gray-800 overflow-hidden shadow-sm sm:rounded-lg">
<div class="p-6">
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">Student Participation</h3>
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Active Students</p>
<p class="text-2xl font-bold">{{ $siswaSubmisi }} / {{ $totalSiswa }}</p>
</div>
<div class="relative w-24 h-24">
<canvas id="participationChart"></canvas>
<div class="absolute inset-0 flex items-center justify-center">
<span class="text-lg font-bold text-gray-900 dark:text-gray-100">{{ $tingkatPartisipasiSiswa }}%</span>
</div>
</div>
</div>
</div>
</div>
<!-- Success Rate -->
<div class="bg-white dark:bg-gray-800 overflow-hidden shadow-sm sm:rounded-lg">
<div class="p-6">
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">Submission Success Rate</h3>
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Completed Submissions</p>
<p class="text-2xl font-bold">{{ $submissionSelesai }} / {{ $totalSubmisi }}</p>
</div>
<div class="relative w-24 h-24">
<canvas id="successRateChart"></canvas>
<div class="absolute inset-0 flex items-center justify-center">
<span class="text-lg font-bold text-gray-900 dark:text-gray-100">{{ $tingkatKeberhasilan ?? 0 }}%</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Detailed Statistics -->
<div class="grid grid-cols-1 gap-4 mb-8 mt-4 w-full">
<div class="bg-white dark:bg-gray-800 overflow-hidden shadow-sm sm:rounded-lg w-full">
<div class="p-6 w-full">
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">Detailed Statistics</h3>
<div class="overflow-x-auto w-full">
<table class="w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead>
<tr>
<th class="px-6 py-3 bg-gray-50 dark:bg-gray-700 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Metric</th>
<th class="px-6 py-3 bg-gray-50 dark:bg-gray-700 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Value</th>
<th class="px-6 py-3 bg-gray-50 dark:bg-gray-700 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Description</th>
</tr>
</thead>
<tbody class="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
<tr>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900 dark:text-gray-100">Projects with Submissions</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">{{ $proyekDenganSubmisi }}</td>
<td class="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">Number of projects that have at least one submission</td>
</tr>
<tr>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900 dark:text-gray-100">Projects without Submissions</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">{{ $proyekTanpaSubmisi }}</td>
<td class="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">Number of projects that have no submissions yet</td>
</tr>
<tr>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900 dark:text-gray-100">Processing Submissions</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">{{ $submisiDalamProses }}</td>
<td class="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">Number of submissions currently being processed</td>
</tr>
<tr>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900 dark:text-gray-100">Pending Submissions</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">{{ $submisiTertunda }}</td>
<td class="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">Number of submissions awaiting action</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
@section('scripts')
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script>
// Submission Status Chart
const submissionStatusCtx = document.getElementById('submissionStatusChart').getContext('2d');
const submissionStatusChart = new Chart(submissionStatusCtx, {
type: 'bar',
data: {
labels: ['Completed', 'Processing', 'Pending', 'Failed'], // Tambah label Failed
datasets: [{
label: 'Submission Status',
data: [{{ $submissionSelesai }}, {{ $submisiDalamProses }}, {{ $submisiTertunda }}, {{ $submissionGagal }}], // Tambah data failed
backgroundColor: [
'rgba(72, 187, 120, 0.7)', // Green for completed
'rgba(66, 153, 225, 0.7)', // Blue for processing
'rgba(237, 137, 54, 0.7)', // Orange for pending
'rgba(220, 53, 69, 0.7)' // Red for failed
],
borderColor: [
'rgba(72, 187, 120, 1)',
'rgba(66, 153, 225, 1)',
'rgba(237, 137, 54, 1)',
'rgba(220, 53, 69, 1)'
],
borderWidth: 1
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
y: {
beginAtZero: true,
ticks: {
precision: 0
}
}
}
}
});
// Project Distribution Chart
const projectDistributionCtx = document.getElementById('projectDistributionChart').getContext('2d');
const projectDistributionChart = new Chart(projectDistributionCtx, {
type: 'pie',
data: {
labels: ['Projects with Submissions', 'Projects without Submissions'],
datasets: [{
data: [{{ $proyekDenganSubmisi }}, {{ $proyekTanpaSubmisi }}],
backgroundColor: [
'rgba(79, 209, 197, 0.7)', // Teal
'rgba(159, 122, 234, 0.7)' // Purple
],
borderColor: [
'rgba(79, 209, 197, 1)',
'rgba(159, 122, 234, 1)'
],
borderWidth: 1
}]
},
options: {
responsive: true,
maintainAspectRatio: false
}
});
// Participation Rate Chart
const participationCtx = document.getElementById('participationChart').getContext('2d');
const participationChart = new Chart(participationCtx, {
type: 'doughnut',
data: {
labels: ['Active', 'Inactive'],
datasets: [{
data: [{{ $siswaSubmisi }}, {{ $totalSiswa - $siswaSubmisi }}],
backgroundColor: [
'rgba(104, 211, 145, 0.7)', // Green
'rgba(226, 232, 240, 0.7)' // Light gray
],
borderColor: [
'rgba(104, 211, 145, 1)',
'rgba(226, 232, 240, 1)'
],
borderWidth: 1
}]
},
options: {
responsive: true,
maintainAspectRatio: true,
cutout: '70%',
plugins: {
legend: {
display: false
}
}
}
});
// Success Rate Chart
const successRateCtx = document.getElementById('successRateChart').getContext('2d');
const successRateChart = new Chart(successRateCtx, {
type: 'doughnut',
data: {
labels: ['Completed', 'Incomplete'],
datasets: [{
data: [{{ $submissionSelesai }}, {{ $totalSubmisi - $submissionSelesai }}],
backgroundColor: [
'rgba(72, 187, 120, 0.7)', // Green
'rgba(226, 232, 240, 0.7)' // Light gray
],
borderColor: [
'rgba(72, 187, 120, 1)',
'rgba(226, 232, 240, 1)'
],
borderWidth: 1
}]
},
options: {
responsive: true,
maintainAspectRatio: true,
cutout: '70%',
plugins: {
legend: {
display: false
}
}
}
});
</script>
@endsection
</x-app-layout>

View File

@ -1,28 +1,15 @@
<x-app-layout>
<x-slot name="header">
<div class="flex justify-between items-center">
<h2 class="font-semibold text-xl text-gray-800 dark:text-gray-200 leading-tight">
{{ __('Dashboard') }}
</h2>
<button id="resetIntroBtn" class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">
<i class="fas fa-question-circle mr-1"></i>Tutorial
</button>
</div>
</x-slot>
<div class="py-4">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<!-- Tips Alert for Dashboard -->
<x-alert.tips-alert pageId="dashboard" title="Tips untuk Anda">
<p>Selamat datang di dashboard! Berikut adalah beberapa tips untuk membantu Anda:</p>
<ul class="list-disc pl-5 mt-2">
<li>Gunakan panel Projects untuk melihat detail project</li>
<li>Untuk submissions bisa dilihat di panel Submissions</li>
<li>Pilih proyek dari dropdown sebelum mengunggah</li>
<li>Unggah file dengan cara drag & drop ZIP atau klik Browse Atau, masukkan link GitHub lengkap di kolom yang tersedia. Lalu klik tombol SUBMIT</li>
<li>Maka anda akan di arahkan ke proses pengujian</li>
<li>Anda bisa kemabali ke halaman dashboard untuk melihat status pengumpulan</li>
</ul>
</x-alert.tips-alert>
</div>
</div>
<div class="pb-5">
<div class="pt-5">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div class="bg-white dark:bg-gray-800 overflow-hidden shadow-sm sm:rounded-lg">
@include('nodejs.dashboard.partials.projects.list')

View File

@ -1,12 +1,21 @@
<div class="flex flex-col max-w-sm rounded-lg overflow-hidden shadow-lg bg-white dark:bg-gray-900 h-full">
<!-- Contoh komponen project card dengan implementasi Intro.js yang lebih lengkap -->
<div class="flex flex-col max-w-sm rounded-lg overflow-hidden shadow-lg bg-white dark:bg-gray-900 h-full"
data-step="1"
data-intro="Klik Read More untuk melihat detail dari project ini."
data-title="Project Card"
data-position="right"
data-disable-interaction="false">
<img class="w-40 mx-auto my-4" src="{{$project->getImageAttribute()}}" alt="Project {{$project->title}}"
onerror="this.onerror=null;this.src='{{ asset('assets/nodejs/placeholder.png') }}';">
<div class="px-6 py-4">
<div class="font-bold text-xl mb-2 text-gray-800 dark:text-white">{{$project->title}}</div>
<p class="text-gray-700 text-base dark:text-gray-400 leading-tight line-clamp-3">
{{ $project->description }}
</p>
</div>
<div class="mt-auto px-6 pt-4 pb-2">
@forelse ($project->tech_stack as $key => $stack)
<span
@ -18,6 +27,7 @@ class="inline-block bg-gray-200 dark:bg-gray-700 rounded-full px-3 py-1 text-sm
tech stack</span>
@endforelse
</div>
<div class="px-6 py-2 bg-gray-100 dark:bg-secondary">
<a href="{{route('projects.show', $project->id)}}"
class="text-xs font-semibold text-secondary dark:text-white uppercase tracking-wide">
@ -26,3 +36,4 @@ class="text-xs font-semibold text-secondary dark:text-white uppercase tracking-w
</a>
</div>
</div>

View File

@ -4,7 +4,11 @@
<div>
<x-input-label for="project_id" :value="__('Select Project Before Uploading')" class="mb-2" />
<select name="project_id" id="project_id"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-secondary-500 focus:border-secondary-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-secondary-500 dark:focus:border-secondary-500">
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-secondary-500 focus:border-secondary-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-secondary-500 dark:focus:border-secondary-500"
data-step="2"
data-intro="Pilih project yang ingin Anda kirimkan. Jika Anda sudah mengirimkan project ini sebelumnya, Anda tidak dapat mengirimkannya lagi."
data-title="Select Project"
data-disable-interaction="false">
<option value="">Select Project</option>
@foreach ($projects as $project)
@php
@ -28,18 +32,30 @@ class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:
</select>
<x-input-error :messages="$errors->get('project_id')" class="mt-2" />
</div>
<div class="mt-4">
<div class="mt-4"
data-step="3"
data-intro="Upload file ZIP project Anda di sini. Pastikan file yang diunggah adalah file ZIP."
data-title="Upload ZIP File"
data-disable-interaction="false">
<x-input-label for="folder" :value="__('Submit The Source Code')" class="mb-2" />
<input type="file" name="folder_path" id="folder" data-allow-reorder="true" data-max-file-size="3MB" />
<x-input-error :messages="$errors->get('folder')" class="mt-2" />
</div>
<div class="mt-4">
<div class="mt-4"
data-step="4"
data-intro="Jika Anda tidak ingin mengunggah file ZIP, Anda dapat memasukkan tautan ke repositori GitHub Anda di sini."
data-title="Or Github Link"
data-disable-interaction="false">
<x-input-label for="github_url" :value="__('Or Github Link')" />
<x-text-input id="github_url" class="block mt-1 w-full" type="text" name="github_url"
:value="old('github_url')" placeholder="E.g. https://github.com/username/repository.git" />
<x-input-error :messages="$errors->get('github_url')" class="mt-2" />
</div>
<div class="flex items-center justify-end mt-12">
<div class="flex items-center justify-end mt-12"
data-step="5"
data-intro="Klik tombol 'Submit' untuk mengirimkan project Anda."
data-title="Submit Project"
data-disable-interaction="false">
<x-primary-button class="ml-4">
{{ __('Submit') }}
</x-primary-button>
@ -84,7 +100,7 @@ class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:
},
},
allowMultiple: false,
acceptedFileTypes: ['application/x-zip-compressed'],
acceptedFileTypes: ['application/x-zip-compressed', 'application/zip', 'application/octet-stream'],
fileValidateTypeDetectType: (source, type) =>
new Promise((resolve, reject) => {
resolve(type);

View File

@ -1,4 +1,8 @@
<table class="table" id="submissions_table">
<table class="table" id="submissions_table"
data-step="6"
data-intro="Tekan title dari project untuk melihat detail dari submission anda."
data-title="Submission Table"
data-disable-interaction="false">
<thead>
<tr>
<th>Title</th>

View File

@ -13,25 +13,70 @@
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"
integrity="sha512-iecdLmaskl7CVkqkXNQ/ZH/XLlvWZOJyj7Yy7tcenmpD1ypASozpmT/E0iPtmFIB46ZmdtAc9eNBvH0H/ZpiBw=="
crossorigin="anonymous" referrerpolicy="no-referrer" />
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.bunny.net">
<link href="https://fonts.bunny.net/css?family=figtree:400,500,600&display=swap" rel="stylesheet" />
<!-- Scripts -->
<!-- Styles -->
<link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/1.13.4/css/jquery.dataTables.min.css" />
<link href="https://unpkg.com/filepond@^4/dist/filepond.css" rel="stylesheet" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"
integrity="sha512-iecdLmaskl7CVkqkXNQ/ZH/XLlvWZOJyj7Yy7tcenmpD1ypASozpmT/E0iPtmFIB46ZmdtAc9eNBvH0H/ZpiBw=="
crossorigin="anonymous" referrerpolicy="no-referrer" />
<link rel="stylesheet" href="https://unpkg.com/intro.js/minified/introjs.min.css" />
<!-- Tambahan: tema Intro.js untuk tampilan yang lebih baik -->
<link rel="stylesheet" href="https://unpkg.com/intro.js/themes/introjs-modern.css" />
<link href="{{asset('css/nodejs_style.css')}}" rel="stylesheet">
<!-- Custom CSS for IntroJS modification -->
<style>
/* Hide the default skip button */
.introjs-skipbutton {
display: none !important;
}
/* Custom navigation container at bottom */
.introjs-tooltipbuttons {
display: flex;
justify-content: space-between;
align-items: center;
}
/* Custom skip button at bottom */
.custom-skip-button {
display: inline-block;
text-decoration: none;
padding: 6px 16px;
font-size: 14px;
font-weight: bold;
line-height: 1.5;
text-align: center;
white-space: nowrap;
vertical-align: middle;
cursor: pointer;
color: white;
background-color: transparent;
border: 1px solid transparent; /* awalnya tidak terlihat */
border-radius: 999px;
transition: all 0.2s ease;
}
.custom-skip-button:hover {
border-color: white;
background-color: transparent;
}
</style>
<!-- Scripts -->
<script src="https://code.jquery.com/jquery-3.6.4.js"></script>
<script src="https://cdn.datatables.net/1.13.4/js/jquery.dataTables.min.js"></script>
<script src="https://unpkg.com/filepond-plugin-file-validate-type/dist/filepond-plugin-file-validate-type.js">
</script>
<script src="https://unpkg.com/filepond-plugin-file-validate-type/dist/filepond-plugin-file-validate-type.js"></script>
<script src="https://unpkg.com/filepond@^4/dist/filepond.js"></script>
<script src="https://unpkg.com/sweetalert/dist/sweetalert.min.js"></script>
<script src="https://unpkg.com/pdfobject@2.2.10/pdfobject.min.js"></script>
<link href="{{asset('css/nodejs_style.css')}}" rel="stylesheet">
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
<script src="https://unpkg.com/intro.js/minified/intro.min.js"></script>
</head>
<body class="font-sans antialiased">
@ -52,6 +97,99 @@
{{ $slot }}
</main>
</div>
<!-- Konfigurasi Intro.js yang dimodifikasi -->
<script>
document.addEventListener("DOMContentLoaded", function () {
// Cek apakah pengguna sudah pernah melihat intro (menggunakan localStorage)
const hasSeenIntro = localStorage.getItem('hasSeenNodeJSIntro');
// Cek apakah ada elemen dengan data-step di halaman
const hasIntroElements = document.querySelectorAll('[data-step]').length > 0;
if (hasIntroElements && !hasSeenIntro) {
startTutorial();
}
// Tambahkan event listener untuk tombol reset tutorial
const resetIntroBtn = document.getElementById('resetIntroBtn');
if (resetIntroBtn) {
resetIntroBtn.addEventListener('click', function() {
localStorage.removeItem('hasSeenNodeJSIntro');
startTutorial();
});
}
// Fungsi untuk memulai tutorial
function startTutorial() {
if (document.querySelectorAll('[data-step]').length === 0) return;
// Konfigurasi yang lebih lengkap untuk intro.js
const intro = introJs();
// Konfigurasi umum
intro.setOptions({
nextLabel: 'Lanjut',
prevLabel: 'Kembali',
skipLabel: 'Lewati', // Ini tidak akan terlihat karena kita sembunyikan button aslinya
doneLabel: 'Selesai',
hidePrev: false,
hideNext: false,
showStepNumbers: true,
showBullets: true,
showProgress: true,
scrollToElement: true,
scrollTo: 'element',
disableInteraction: false,
tooltipPosition: 'auto',
highlightClass: 'intro-highlight',
tooltipClass: 'customTooltip',
exitOnOverlayClick: false,
exitOnEsc: true,
overlayOpacity: 0.8
});
// Event untuk memodifikasi tooltips setelah mereka dibuat
intro.onafterchange(function(targetElement) {
// Sembunyikan tombol skip default dan buat tombol skip kustom
setTimeout(function() {
const tooltipButtons = document.querySelector('.introjs-tooltipbuttons');
const skipExists = tooltipButtons.querySelector('.custom-skip-button');
// Hanya tambahkan tombol jika belum ada
if (!skipExists) {
const skipButton = document.createElement('button');
skipButton.className = 'custom-skip-button';
skipButton.innerHTML = 'Lewati';
// Tambahkan event untuk skip
skipButton.addEventListener('click', function() {
intro.exit();
localStorage.setItem('hasSeenNodeJSIntro', 'true');
});
// Tempatkan tombol Lewati di sebelah paling kanan
const nextButtons = tooltipButtons.querySelector('.introjs-nextbutton');
tooltipButtons.appendChild(skipButton);
}
}, 10);
});
// Jalankan tutorial
intro.start();
// Simpan bahwa pengguna sudah melihat intro
intro.oncomplete(function() {
localStorage.setItem('hasSeenNodeJSIntro', 'true');
});
intro.onexit(function() {
localStorage.setItem('hasSeenNodeJSIntro', 'true');
});
}
});
</script>
@yield('scripts')
</body>

View File

@ -1,40 +1,48 @@
<nav x-data="{ open: false }" class="bg-white dark:bg-gray-800 border-b border-gray-100 dark:border-gray-700">
<!-- Primary Navigation Menu -->
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16">
<div class="flex">
<div class="flex justify-between items-center h-16">
<!-- Left side: Logo and Navigation Links -->
<div class="flex items-center">
<!-- Logo -->
<div class="shrink-0 flex items-center">
<a href="{{ route('dashboard.nodejs') }}">
<a href="#">
<x-application-logo class="block h-9 w-auto fill-current text-gray-800" />
</a>
</div>
<!-- Navigation Links -->
<div class="hidden space-x-8 sm:-my-px sm:ml-10 sm:flex">
<x-nav-link :href="route('dashboard.nodejs')" :active="request()->routeIs('dashboard*')">
@if (Auth::user()->role === 'student')
<x-nav-link :href="route('dashboard.nodejs')" :active="request()->routeIs('dashboard-student')">
{{ __('Dashboard') }}
</x-nav-link>
<x-nav-link :href="route('projects')" :active="request()->routeIs('projects*')">
<x-nav-link :href="route('projects')" :active="request()->routeIs('projects.student')">
{{ __('Projects') }}
</x-nav-link>
<x-nav-link :href="route('submissions')" :active="request()->routeIs('submissions*')">
<x-nav-link :href="route('submissions')" :active="request()->routeIs('submissions.student')">
{{ __('Submissions') }}
</x-nav-link>
@elseif (Auth::user()->role === 'teacher')
<x-nav-link :href="route('dashboard.nodejs.teacher')" :active="request()->routeIs('dashboard-teacher')">
{{ __('Dashboard') }}
</x-nav-link>
<x-nav-link :href="route('nodejs.teacher.rank')" :active="request()->routeIs('nodejs.teacher.rank')">
{{ __('Rank') }}
</x-nav-link>
@endif
</div>
</div>
<!-- Settings Dropdown -->
<div class="hidden sm:flex sm:items-center sm:ml-6">
<!-- Right side: User Dropdown -->
<div class="hidden sm:flex sm:items-center ml-auto">
<x-dropdown align="right" width="48">
<x-slot name="trigger">
<button
class="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-gray-500 dark:text-gray-400 bg-white dark:bg-gray-800 hover:text-gray-700 dark:hover:text-gray-300 focus:outline-none transition ease-in-out duration-150">
<button class="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-gray-500 dark:text-gray-400 bg-white dark:bg-gray-800 hover:text-gray-700 dark:hover:text-gray-300 focus:outline-none transition ease-in-out duration-150">
<div>{{ Auth::user()->name }}</div>
<div class="ml-1">
<svg class="fill-current h-4 w-4" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20">
<svg class="fill-current h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
<path fill-rule="evenodd"
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
clip-rule="evenodd" />
@ -44,24 +52,33 @@ class="inline-flex items-center px-3 py-2 border border-transparent text-sm lead
</x-slot>
<x-slot name="content">
{{-- Profile Link (Bisa ditampilkan untuk semua role) --}}
<x-dropdown-link :href="route('profile.edit')">
{{ __('Profile') }}
</x-dropdown-link>
<!-- Authentication -->
<form method="POST" action="{{ route('logout') }}">
{{-- Authentication / Logout Berdasarkan Role --}}
@if (Auth::user()->role === 'student')
<form method="GET" action="{{ route('dashboard-student') }}">
@csrf
<x-dropdown-link :href="route('logout')" onclick="event.preventDefault();
this.closest('form').submit();">
<x-dropdown-link href="{{ route('dashboard-student') }}" onclick="event.preventDefault(); this.closest('form').submit();">
{{ __('Log Out') }}
</x-dropdown-link>
</form>
@elseif (Auth::user()->role === 'teacher')
<form method="GET" action="{{ route('dashboard-teacher') }}">
@csrf
<x-dropdown-link href="{{ route('dashboard-teacher') }}" onclick="event.preventDefault(); this.closest('form').submit();">
{{ __('Log Out') }}
</x-dropdown-link>
</form>
@endif
</x-slot>
</x-dropdown>
</div>
<!-- Hamburger -->
<!-- Hamburger (Mobile) -->
<div class="-mr-2 flex items-center sm:hidden">
<button @click="open = ! open"
class="inline-flex items-center justify-center p-2 rounded-md text-gray-400 dark:text-gray-500 hover:text-gray-500 dark:hover:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-900 focus:outline-none focus:bg-gray-100 dark:focus:bg-gray-900 focus:text-gray-500 dark:focus:text-gray-400 transition duration-150 ease-in-out">
@ -69,8 +86,9 @@ class="inline-flex items-center justify-center p-2 rounded-md text-gray-400 dark
<path :class="{'hidden': open, 'inline-flex': ! open }" class="inline-flex"
stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 6h16M4 12h16M4 18h16" />
<path :class="{'hidden': ! open, 'inline-flex': open }" class="hidden" stroke-linecap="round"
stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
<path :class="{'hidden': ! open, 'inline-flex': open }" class="hidden"
stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
@ -80,6 +98,7 @@ class="inline-flex items-center justify-center p-2 rounded-md text-gray-400 dark
<!-- Responsive Navigation Menu -->
<div :class="{'block': open, 'hidden': ! open}" class="hidden sm:hidden">
<div class="pt-2 pb-3 space-y-1">
@if (Auth::user()->role === 'student')
<x-responsive-nav-link :href="route('dashboard.nodejs')" :active="request()->routeIs('dashboard*')">
{{ __('Dashboard') }}
</x-responsive-nav-link>
@ -89,6 +108,14 @@ class="inline-flex items-center justify-center p-2 rounded-md text-gray-400 dark
<x-responsive-nav-link :href="route('submissions')" :active="request()->routeIs('submissions*')">
{{ __('Submissions') }}
</x-responsive-nav-link>
@elseif (Auth::user()->role === 'teacher')
<x-responsive-nav-link :href="route('dashboard.nodejs.teacher')" :active="request()->routeIs('dashboard*')">
{{ __('Dashboard') }}
</x-responsive-nav-link>
<x-responsive-nav-link :href="route('nodejs.teacher.rank')" :active="request()->routeIs('nodejs.teacher.rank')">
{{ __('Rank') }}
</x-responsive-nav-link>
@endif
</div>
<!-- Responsive Settings Options -->
@ -103,15 +130,21 @@ class="inline-flex items-center justify-center p-2 rounded-md text-gray-400 dark
{{ __('Profile') }}
</x-responsive-nav-link>
<!-- Authentication -->
<form method="POST" action="{{ route('logout') }}">
@if (Auth::user()->role === 'student')
<form method="GET" action="{{ route('dashboard-student') }}">
@csrf
<x-responsive-nav-link :href="route('logout')" onclick="event.preventDefault();
this.closest('form').submit();">
<x-responsive-nav-link :href="route('dashboard-student')" onclick="event.preventDefault(); this.closest('form').submit();">
{{ __('Log Out') }}
</x-responsive-nav-link>
</form>
@elseif (Auth::user()->role === 'teacher')
<form method="GET" action="{{ route('dashboard-teacher') }}">
@csrf
<x-responsive-nav-link :href="route('dashboard-teacher')" onclick="event.preventDefault(); this.closest('form').submit();">
{{ __('Log Out') }}
</x-responsive-nav-link>
</form>
@endif
</div>
</div>
</div>

View File

@ -11,7 +11,9 @@
@php
$guidesCount = count($project->getMedia('project_guides'));
$supplementsCount = count($project->getMedia('project_supplements'));
$testsCount = count($project->getMedia('project_tests_api')) + count($project->getMedia('project_tests_web'));
$testsIndividualCount = count($project->getMedia('project_tests'));
$testsZipExists = $project->getMedia('project_zips')->where('file_name', 'tests.zip')->first();
$testsCount = $testsIndividualCount > 0 || $testsZipExists ? max($testsIndividualCount, 1) : 0;
@endphp
<div>
@if ($guidesCount > 0)

View File

@ -1,42 +1,31 @@
<x-app-layout>
<x-slot name="header">
<h2 class="font-semibold text-xl text-gray-800 dark:text-gray-200 leading-tight">
<div class="flex justify-between items-center">
<h2 class="font-semibold text-xl text-gray-800 dark:text-gray-200 leading-tight" data-step="1" data-intro="Ini adalah judul dari project yang sedang Anda lihat saat ini" data-title="Project Name" data-position="top" data-disable-interaction="false">
{{ __('Project:') . ' ' . $project->title }}
</h2>
<button id="resetIntroBtn" class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">
<i class="fas fa-question-circle mr-1"></i> Tutorial
</button>
</div>
</x-slot>
<div class="py-4">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<!-- Tips Alert for Dashboard -->
<x-alert.tips-alert pageId="project" title="Tips untuk Anda">
<p>Selamat datang di project! Berikut adalah beberapa tips untuk membantu Anda:</p>
<ul class="list-disc pl-5 mt-2">
<li>Klik ikon garis 3 di sebelah nama guide untuk membuka opsi</li>
<li>Pilih "View" untuk melihat PDF di halaman yang sama</li>
<li>Pilih "Open in a new tab" untuk membuka di tab baru</li>
<li>Pilih "Download" untuk menyimpan PDF ke perangkat Anda</li>
<li>Klik "All Guides" di panel Download untuk mengunduh semua panduan sekaligus</li>
<li>Klik "All Supplements" di panel Download untuk mengunduh file tambahan/pendukung</li>
</ul>
</x-alert.tips-alert>
</div>
</div>
<div class="pt-5">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div class="bg-white dark:bg-gray-800 overflow-hidden shadow-sm sm:rounded-lg">
<div class="bg-white dark:bg-gray-800 overflow-hidden shadow-sm sm:rounded-lg" data-step="2" data-intro="Bagian ini berisi detail project termasuk judul, deskripsi dan teknologi yang digunakan dalam project ini" data-title="Project Detail" data-position="top" data-disable-interaction="false">
@include('nodejs.projects.partials.details')
</div>
</div>
</div>
<div class="py-5">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div class="bg-white dark:bg-gray-800 overflow-hidden shadow-sm sm:rounded-lg pb-18">
<div class="bg-white dark:bg-gray-800 overflow-hidden shadow-sm sm:rounded-lg pb-18" data-step="3" data-intro="Di bagian ini, Anda dapat melihat semua panduan PDF terkait project ini. Klik 'View' untuk membuka panduan PDF langsung di halaman ini" data-title="Project Guide" data-position="top" data-disable-interaction="false">
@include('nodejs.projects.partials.guides')
</div>
</div>
</div>
<div class="py-5">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div class="bg-white dark:bg-gray-800 overflow-hidden shadow-sm sm:rounded-lg">
<div class="bg-white dark:bg-gray-800 overflow-hidden shadow-sm sm:rounded-lg" data-step="4" data-intro="Di sini Anda dapat mengunduh semua materi project sekaligus berdasarkan kategori - panduan, materi tambahan, dan tes" data-title="Project Download" data-position="top" data-disable-interaction="false">
@include('nodejs.projects.partials.downloads')
</div>
</div>

View File

@ -0,0 +1,212 @@
<x-app-layout>
<x-slot name="header">
<h2 class="font-semibold text-xl text-gray-800 dark:text-gray-200 leading-tight">
{{ __('Student Rankings') }}
</h2>
</x-slot>
<div class="py-6">
<div class="w-full mx-auto px-4 sm:px-6 lg:px-8">
<div class=" dark:bg-slate-800 overflow-hidden shadow-xl sm:rounded-lg border dark:border-gray-700">
<!-- Header with project selector -->
<div class="flex flex-col md:flex-row justify-between items-center dark:bg-slate-900 p-4 border-b border-gray-200 dark:border-gray-700">
<span class="text-xl font-bold text-gray-800 dark:text-white mb-4 md:mb-0">Student Rankings</span>
<div class="w-full md:w-96">
<select id="project-selector" class= "bg-gray-50 border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-secondary-500 focus:border-secondary-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-secondary-500 dark:focus:border-secondary-500">
<option value="">Select Project</option>
</select>
</div>
</div>
<div class="p-6">
<!-- Loading indicator -->
<div id="loading" class="text-center my-8 hidden">
<div class="inline-block animate-spin rounded-full h-12 w-12 border-4 border-blue-500 border-t-transparent"></div>
<p class="mt-4 text-gray-600 dark:text-gray-400">Loading data...</p>
</div>
<!-- No project selected message -->
<div id="no-project-selected" class="text-center my-12">
<svg class="mx-auto h-16 w-16 text-gray-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
<p class="mt-4 text-lg font-medium text-gray-600 dark:text-gray-400">Please select a project to view rankings</p>
</div>
<!-- Rankings data container -->
<div id="ranking-data" class="hidden">
<!-- Export button -->
<div class="flex justify-end mb-5">
<button id="export-csv" class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors flex items-center shadow-md">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
Export to CSV
</button>
</div>
<!-- Rankings table -->
<div class="overflow-x-auto dark:bg-slate-800 rounded-lg shadow-md">
<table class="w-full table-fixed divide-y divide-gray-200 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-700 text-gray-500 dark:text-gray-300">
<tr>
<th scope="col" class="w-1/12 px-6 py-4 text-left text-xs font-bold text-gray-600 dark:text-white uppercase tracking-wider">Rank</th>
<th scope="col" class="w-3/12 px-6 py-4 text-left text-xs font-bold text-gray-600 dark:text-white uppercase tracking-wider">Name</th>
<th scope="col" class="w-1/12 px-6 py-4 text-left text-xs font-bold text-gray-600 dark:text-white uppercase tracking-wider">Attempts</th>
<th scope="col" class="w-1/12 px-6 py-4 text-left text-xs font-bold text-gray-600 dark:text-white uppercase tracking-wider">Score</th>
<th scope="col" class="w-1/12 px-6 py-4 text-left text-xs font-bold text-gray-600 dark:text-white uppercase tracking-wider">Passed</th>
<th scope="col" class="w-1/12 px-6 py-4 text-left text-xs font-bold text-gray-600 dark:text-white uppercase tracking-wider">Total</th>
<th scope="col" class="w-2/12 px-6 py-4 text-left text-xs font-bold text-gray-600 dark:text-white uppercase tracking-wider">Last Submission</th>
</tr>
</thead>
<tbody id="ranking-table-body" class="divide-y divide-gray-200 dark:divide-gray-700">
<!-- Rankings will be populated here -->
</tbody>
</table>
</div>
</div>
<!-- No data message -->
<div id="no-data" class="text-center my-12 hidden">
<svg class="mx-auto h-16 w-16 text-gray-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M12 14h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p class="mt-4 text-lg font-medium text-gray-600 dark:text-gray-400">No submissions found for this project</p>
</div>
</div>
</div>
</div>
</div>
@section('scripts')
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script>
<script>
$(document).ready(function() {
// Load projects for dropdown
loadProjects();
// Project selection change event
$('#project-selector').change(function() {
const projectId = $(this).val();
if (projectId) {
loadRankings(projectId);
} else {
$('#ranking-data').hide();
$('#no-project-selected').show();
$('#no-data').hide();
}
});
// Export to CSV button click event
$('#export-csv').click(function() {
const projectId = $('#project-selector').val();
if (projectId) {
window.location.href = `{{ route('nodejs.teacher.rank.export') }}?project_id=${projectId}`;
}
});
});
function loadProjects() {
$.ajax({
url: "{{ route('nodejs.teacher.rank.projects') }}",
type: "GET",
beforeSend: function() {
$('#loading').show();
},
success: function(response) {
if (response.status === 'success') {
const projects = response.data.projects;
let options = '<option value="">Select Project</option>';
projects.forEach(project => {
options += `<option value="${project.id}">${project.title}</option>`;
});
$('#project-selector').html(options);
}
},
error: function(xhr) {
console.error('Error loading projects', xhr);
alert('Gagal memuat proyek. Silakan coba lagi.');
},
complete: function() {
$('#loading').hide();
}
});
}
function loadRankings(projectId) {
$.ajax({
url: "{{ route('nodejs.teacher.rank.by-project') }}",
type: "GET",
data: {
project_id: projectId
},
beforeSend: function() {
$('#ranking-data').hide();
$('#no-project-selected').hide();
$('#no-data').hide();
$('#loading').show();
},
success: function(response) {
if (response.status === 'success') {
const rankings = response.data.rankings;
if (rankings.length > 0) {
populateRankingsTable(rankings);
$('#ranking-data').show();
} else {
$('#no-data').show();
}
}
},
error: function(xhr) {
console.error('Error loading rankings', xhr);
alert('Failed to load ranking. Please try again..');
},
complete: function() {
$('#loading').hide();
}
});
}
function populateRankingsTable(rankings) {
let tableRows = '';
rankings.forEach(student => {
const submissionDate = new Date(student.submission_date).toLocaleString();
// Adding special class for top 3 performers with improved contrast
let rowClass = '';
if (student.rank <= 3) {
rowClass = 'top-performer';
}
tableRows += `
<tr class="${rowClass}">
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900 dark:text-white">
${student.rank <= 3 ?
`<span class="inline-flex items-center justify-center w-6 h-6 rounded-full ${
student.rank === 1 ? 'bg-yellow-400 gold-medal' :
student.rank === 2 ? 'bg-gray-300 silver-medal' :
'bg-amber-600 bronze-medal'} text-white font-bold">
${student.rank}
</span>` :
student.rank
}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-white">${student.user_name}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-white">${student.attempts}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-white font-medium">${student.score}%</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-white">${student.passed_tests}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-white">${student.total_tests}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-white">${submissionDate}</td>
</tr>
`;
});
$('#ranking-table-body').html(tableRows);
}
</script>
@endsection
</x-app-layout>

View File

@ -1,24 +1,17 @@
<x-app-layout>
<x-slot name="header">
<div class="flex items-center justify-between">
<h2 class="font-semibold text-xl text-gray-800 dark:text-gray-200 leading-tight">
{{ __('Submissions') }}
</h2>
<button id="resetIntroBtn" class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">
<i class="fas fa-question-circle mr-1"></i>Tutorial
</button>
</div>
</x-slot>
<div class="py-4">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<!-- Tips Alert for Dashboard -->
<x-alert.tips-alert pageId="submission-index" title="Tips untuk Anda">
<p>Selamat datang di Submission! Berikut adalah beberapa tips untuk membantu Anda:</p>
<ul class="list-disc pl-5 mt-2">
<li>Perhatikan kolom "Status" untuk melihat hasil pengajuan Anda</li>
<li>Klik ikon menu garis 3 untuk melihat opsi tindakan</li>
<li>Pilih "Restart submission" untuk mencoba ulang pengajuan yang gagal</li>
<li>Pilih "Delete submission" untuk menghapus pengajuan dari daftar</li>
<li>Klik judul project untuk melihat history dan lebih detail mengenai submission</li>
</ul>
</x-alert.tips-alert>
</div>
</div>
<div class="py-4"></div>
<div class="py-2">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div class="bg-white dark:bg-gray-800 overflow-hidden shadow-sm sm:rounded-lg pb-12">

View File

@ -11,6 +11,7 @@
<tbody>
</tbody>
</table>
@section('scripts')
<script type="text/javascript">
function requestServer(element){
@ -88,29 +89,80 @@ function requestServer(element){
window.location = "/nodejs/submissions";
});
},
error: function(data) {
error: function(xhr, status, error) {
let errorMessage = "Something went wrong!";
console.log("Error status:", xhr.status);
console.log("Error response:", xhr.responseText);
// Check if it's a 403 error (maximum attempts reached)
if (xhr.status === 403) {
try {
let response = JSON.parse(xhr.responseText);
errorMessage = response.message || "Maximum attempts reached. Cannot restart submission.";
} catch (e) {
errorMessage = "Maximum attempts reached. Cannot restart submission.";
}
} else if (xhr.responseJSON && xhr.responseJSON.message) {
errorMessage = xhr.responseJSON.message;
}
swal({
title: "Error!",
text: "Something went wrong!",
text: errorMessage,
icon: "error",
button: "Ok",
});
console.log(data);
}
});
}
});
break;
case "change-source-code":
// redirect to change source code page
// First check if attempts limit is reached via AJAX
$.ajax({
url: '/nodejs/submissions/change/' + submission_id,
type: 'GET',
headers: {
'X-CSRF-TOKEN': '{{ csrf_token()}}'
},
success: function(data) {
// If successful, redirect to the change source code page
window.location = '/nodejs/submissions/change/' + submission_id;
},
error: function(xhr, status, error) {
// Handle 403 error (maximum attempts reached)
if (xhr.status === 403) {
let errorMessage = "Maximum attempts reached. Cannot change source code.";
try {
let response = JSON.parse(xhr.responseText);
errorMessage = response.message || errorMessage;
} catch (e) {
// Use default message if parsing fails
}
swal({
title: "Error!",
text: errorMessage,
icon: "error",
button: "Ok",
});
} else {
swal({
title: "Error!",
text: "Something went wrong!",
icon: "error",
button: "Ok",
});
}
}
});
break;
default:
break;
}
}
$(function () {
var table = $('#submissions_table').DataTable({
"processing": true,
@ -135,11 +187,51 @@ function requestServer(element){
},
ajax: "{{ route('submissions') }}",
columns: [
{data: 'title', name: 'title', orderable: true, searchable: true},
// {data: 'submission_count', name: 'submission_count', orderable: true, searchable: false, className: "text-center"},
{data: 'attempts_count', name: 'attempts_count', orderable: true, searchable: false, className: "text-center"},
{data: 'submission_status', name: 'submission_status', orderable: true, searchable: true, className: "text-center"},
{data: 'action', name: 'action', orderable: false, searchable: false, className: "text-center"},
{
data: 'title',
name: 'title',
orderable: true,
searchable: true,
createdCell: function (td) {
$(td).attr({
'data-step': '1',
'data-intro': 'Klik Tittle Project untuk melihat lebih detail submission, anda dapat dapat melihat detail pengujian dan mendownload hasil pengujiannya.',
'data-title': 'Submission Detail',
'data-position': 'right',
'data-disable-interaction': 'false'
});
}
},
{
data: 'attempts_count',
name: 'attempts_count',
orderable: true,
searchable: false,
className: "text-center"
},
{
data: 'submission_status',
name: 'submission_status',
orderable: true,
searchable: true,
className: "text-center"
},
{
data: 'action',
name: 'action',
orderable: false,
searchable: false,
className: "text-center",
createdCell: function (td) {
$(td).attr({
'data-step': '2',
'data-intro': 'Jika anda sudah mensubmit sebuah project, anda dapat mengulang dan menghapus submission project tersebut dengan mengklik tombol yang muncul disini.',
'data-title': 'Submission Action',
'data-position': 'right',
'data-disable-interaction': 'false'
});
}
},
]
});
});

View File

@ -6,16 +6,6 @@
</h2>
</x-slot>
<div class="py-4">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<!-- Tips Alert for Dashboard -->
<x-alert.tips-alert pageId="submission-show" title="Tips untuk Anda">
<p>Selamat datang di Submission! Berikut adalah beberapa tips untuk membantu Anda:</p>
<ul class="list-disc pl-5 mt-2">
<li>Klik "View" untuk melihat feedback atau error log dari sistem</li>
<li>Klik "Download Results" untuk mengunduh laporan berupa json</li>
</ul>
</x-alert.tips-alert>
</div>
</div>
<div class="py-5">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">

View File

@ -5,6 +5,8 @@
use App\Http\Controllers\NodeJS\Student\SubmissionController;
use App\Http\Controllers\NodeJS\Student\WelcomeController;
use App\Http\Controllers\NodeJS\Student\ProfileController;
use App\Http\Controllers\NodeJS\Teacher\DashboardController as TeacherDashboardController;
use App\Http\Controllers\NodeJS\Teacher\RankController;
use Illuminate\Support\Facades\Route;
@ -14,11 +16,14 @@
Route::middleware('auth')->group(function () {
// Dashboard
Route::get('/dashboard/teacher', [TeacherDashboardController::class, 'index'])->name('dashboard.nodejs.teacher');
Route::get('/dashboard', [DashboardController::class, 'index'])->name('dashboard.nodejs');
// Profile
Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit');
Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update');
Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy');
// Projects
Route::prefix('projects')->controller(ProjectController::class)->group(function () {
Route::get('/', 'index')->name('projects');
@ -26,6 +31,7 @@
Route::get('/project/{project_id}/download', 'download')->name('projects.download');
Route::get('pdf', 'showPDF')->name('projects.pdf');
});
// Submissions
Route::prefix('submissions')->group(function () {
// show all the submission based on the projects
@ -57,5 +63,17 @@
// update source code based on the submission id
Route::post('/update/submission', [SubmissionController::class, 'update'])->name('submissions.update');
});
// Teacher Rankings
Route::prefix('teacher/rank')->controller(RankController::class)->group(function () {
// Display ranking page
Route::get('/', 'index')->name('nodejs.teacher.rank');
// Get ranking data for a specific project
Route::get('/by-project', 'getRankingByProject')->name('nodejs.teacher.rank.by-project');
// Get all projects for the ranking dropdown
Route::get('/projects', 'getProjects')->name('nodejs.teacher.rank.projects');
// Export ranking data to CSV
Route::get('/export', 'exportRanking')->name('nodejs.teacher.rank.export');
});
});
});

View File

@ -4,6 +4,7 @@
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\AuthController;
use App\Http\Controllers\DataController;
use App\Http\Controllers\NodeJS\NodeJSController;
use Laravel\Socialite\Facades\Socialite;
use App\Http\Controllers\SocialController;
@ -22,7 +23,7 @@
// Route::get('/', function () {
// return view('welcome');
// });
Route::get('/p', [AuthController::class, 'logout']);
Route::post('/login',[AuthController::class,'proses'])->name('login');
Route::post('/signup',[AuthController::class,'signup'])->name('post_signup');
Route::get('/auth/redirect', action:[AuthController::class, 'redirect'])->name(name:'google.redirect');
@ -42,21 +43,21 @@
return view('signup');
})->name('signup');
Route::post('/logoutt', [AuthController::class, 'logoutt'])
->name('logoutt');
Route::get('/logout', [AuthController::class, 'logoutt'])
Route::post('/logout', [AuthController::class, 'logout'])
->name('logout');
Route::get('/logout', [AuthController::class, 'logout'])
->name('logout');
// Route::group(["prefix" => 'test', 'middleware' => ['login'], 'as' => 'test.'], function(){
Route::get('/dashboard-student', function () {
return view('dashboard_student');
})->name('dashboard-student')->middleware('auth');
Route::get('/dashboard_teacher', [NodeJSController::class, 'dashboardTeacher'])
->name('dashboard-teacher')
->middleware('auth');
Route::get('/dashboard_teacher', function () {
return view('dashboard_student');
})->name('dashboard-teacher')->middleware('auth');
Route::get('/dashboard-student', [NodeJSController::class, 'dashboardStudent'])
->name('dashboard-student')
->middleware('auth');
Route::get('/learning-student', function () {
return view('learning_student');

2
storage/projects/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
!.env
!.gitignore

View File

@ -0,0 +1,7 @@
MONGODB_URI=mongodb+srv://<username>:<password>@<cluster_name>.incnimg.mongodb.net/<database_name>?retryWrites=true&w=majority
MONGODB_URI_TEST=mongodb+srv://<username>:<password>@<cluster_name>.incnimg.mongodb.net/<database_name-test>?retryWrites=true&w=majority
PORT=choose_a_port
# replace all <> with your own credentials as provided by MongoDB Atlas (https://www.mongodb.com/cloud/atlas)
# it is recommended to use a different database for testing hence the -test suffix in the MONGODB_URI_TEST
# choose a port that is not already in use (e.g. 5000)

View File

@ -0,0 +1,61 @@
{
"name": "api-experiment",
"version": "1.0.0",
"description": "NodeJS and MongoDB API application",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js",
"api-testA01": "cross-env NODE_ENV=test jest -i tests/api/testA01.test.js --testTimeout=20000",
"web-testA01": "cross-env NODE_ENV=test jest -i tests/web/testA01.test.js --testTimeout=20000",
"api-testA02": "cross-env NODE_ENV=test jest -i tests/api/testA02.test.js --testTimeout=20000",
"web-testA02": "cross-env NODE_ENV=test jest -i tests/web/testA02.test.js --testTimeout=20000",
"api-testA03": "cross-env NODE_ENV=test jest -i tests/api/testA03.test.js --testTimeout=20000",
"web-testA03": "cross-env NODE_ENV=test jest -i tests/web/testA03.test.js --testTimeout=20000",
"api-testA04": "cross-env NODE_ENV=test jest -i tests/api/testA04.test.js --testTimeout=20000",
"web-testA04": "cross-env NODE_ENV=test jest -i tests/web/testA04.test.js --testTimeout=20000",
"api-testA05": "cross-env NODE_ENV=test jest -i tests/api/testA05.test.js --testTimeout=20000",
"web-testA05": "cross-env NODE_ENV=test jest -i tests/web/testA05.test.js --testTimeout=20000",
"testAA": "cross-env NODE_ENV=test jest --testTimeout=40000 --silent --detectOpenHandles"
},
"jest": {
"setupFilesAfterEnv": [
"jest-expect-message"
],
"noStackTrace": true,
"silent": false
},
"repository": {
"type": "git",
"url": "git+https://github.com/Omar630603/api-experiment.git"
},
"keywords": [
"NodeJS",
"ExpressJS",
"API",
"MongoDB"
],
"author": "Omar630603",
"license": "ISC",
"bugs": {
"url": "https://github.com/Omar630603/api-experiment/issues"
},
"homepage": "https://github.com/Omar630603/api-experiment#readme",
"devDependencies": {
"cross-env": "^7.0.3",
"jest": "^29.5.0",
"jest-expect-message": "^1.1.3",
"jest-image-snapshot": "^6.1.0",
"nodemon": "^2.0.21",
"puppeteer": "^19.7.4",
"supertest": "^6.3.3"
},
"dependencies": {
"dotenv": "^16.0.3",
"ejs": "^3.1.8",
"express": "^4.18.2",
"express-ejs-layouts": "^2.5.1",
"mongoose": "^6.10.0",
"mongoose-slug-generator": "^1.0.4"
}
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@ -0,0 +1,7 @@
MONGODB_URI=mongodb+srv://<username>:<password>@<cluster_name>.incnimg.mongodb.net/<database_name>?retryWrites=true&w=majority
MONGODB_URI_TEST=mongodb+srv://<username>:<password>@<cluster_name>.incnimg.mongodb.net/<database_name-test>?retryWrites=true&w=majority
PORT=choose_a_port
# replace all <> with your own credentials as provided by MongoDB Atlas (https://www.mongodb.com/cloud/atlas)
# it is recommended to use a different database for testing hence the -test suffix in the MONGODB_URI_TEST
# choose a port that is not already in use (e.g. 5000)

View File

@ -0,0 +1,8 @@
node_modules
build
npm-debug.log
coverage
.nyc
.env
.DS_Store
tests/web/images/__diff_output__

View File

@ -0,0 +1,92 @@
[
{
"name": "Red T-shirt",
"price": 15.99,
"description": "A comfortable and stylish red T-shirt",
"slug": "red-t-shirt",
"createdAt": "2023-02-20T07:32:14.786Z",
"updatedAt": "2023-02-20T07:32:14.786Z",
"_id": { "$oid": "621578ce4f4a2e4c6406dc04" }
},
{
"name": "Black Jeans",
"price": 49.99,
"description": "A pair of black jeans with a slim fit",
"slug": "black-jeans",
"createdAt": "2023-02-20T07:32:14.786Z",
"updatedAt": "2023-02-20T07:32:14.786Z",
"_id": { "$oid": "621578ce4f4a2e4c6406dc05" }
},
{
"name": "Blue Hoodie",
"price": 29.99,
"description": "A cozy and warm blue hoodie with a front pocket",
"slug": "blue-hoodie",
"createdAt": "2023-02-20T07:32:14.786Z",
"updatedAt": "2023-02-20T07:32:14.786Z",
"_id": { "$oid": "621578ce4f4a2e4c6406dc06" }
},
{
"name": "White Sneakers",
"price": 79.99,
"description": "A pair of white sneakers with a comfortable sole",
"slug": "white-sneakers",
"createdAt": "2023-02-20T07:32:14.786Z",
"updatedAt": "2023-02-20T07:32:14.786Z",
"_id": { "$oid": "621578ce4f4a2e4c6406dc07" }
},
{
"name": "Yellow Raincoat",
"price": 45.99,
"description": "A waterproof yellow raincoat with a hood",
"slug": "yellow-raincoat",
"createdAt": "2023-02-20T07:32:14.786Z",
"updatedAt": "2023-02-20T07:32:14.786Z",
"_id": { "$oid": "621578ce4f4a2e4c6406dc08" }
},
{
"name": "Green Tote Bag",
"price": 19.99,
"description": "A sturdy green tote bag with a spacious interior",
"slug": "green-tote-bag",
"createdAt": "2023-02-20T07:32:14.786Z",
"updatedAt": "2023-02-20T07:32:14.786Z",
"_id": { "$oid": "621578ce4f4a2e4c6406dc09" }
},
{
"name": "Black T-shirt",
"price": 15.99,
"description": "A comfortable and stylish black T-shirt",
"slug": "black-t-shirt",
"createdAt": "2023-02-20T07:32:14.786Z",
"updatedAt": "2023-02-20T07:32:14.786Z",
"_id": { "$oid": "621578ce4f4a2e4c6406dc10" }
},
{
"name": "Green T-shirt",
"price": 15.99,
"description": "A comfortable and stylish green T-shirt",
"slug": "green-t-shirt",
"createdAt": "2023-02-20T07:32:14.786Z",
"updatedAt": "2023-02-20T07:32:14.786Z",
"_id": { "$oid": "621578ce4f4a2e4c6406dc11" }
},
{
"name": "Black Sneakers",
"price": 79.99,
"description": "A pair of black sneakers with a comfortable sole",
"slug": "black-sneakers",
"createdAt": "2023-02-20T07:32:14.786Z",
"updatedAt": "2023-02-20T07:32:14.786Z",
"_id": { "$oid": "621578ce4f4a2e4c6406dc12" }
},
{
"name": "Red Sneakers",
"price": 79.99,
"description": "A pair of red sneakers with a comfortable sole",
"slug": "red-sneakers",
"createdAt": "2023-02-20T07:32:14.786Z",
"updatedAt": "2023-02-20T07:32:14.786Z",
"_id": { "$oid": "621578ce4f4a2e4c6406dc13" }
}
]

View File

@ -0,0 +1,240 @@
:root {
--dark-primary-color: #231a2a;
--light-primary-color: #efa25f;
--light-secondary-color: #027d92;
--light-background-color: #f5f5f5;
--dark-background-color: #1a1a1a;
--text-color: #fff;
--link-color: #00a1bd;
--danger-color: #ef5f5f;
--danger-color-hover: #be4242;
}
body {
font-family: "Poppins", sans-serif;
background-color: var(--dark-background-color);
color: var(--text-color);
margin: 0;
}
nav {
background-color: #333;
overflow: hidden;
}
nav ul {
list-style-type: none;
margin: 0;
padding: 0;
}
nav li {
float: left;
}
nav li a {
display: block;
color: white;
text-align: center;
padding: 14px 16px;
text-decoration: none;
}
nav li a:hover {
background-color: #ddd;
color: black;
}
.container {
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
.title {
font-size: 3rem;
margin-bottom: 30px;
text-align: center;
}
.subtitle {
font-size: 2rem;
margin-bottom: 20px;
text-align: center;
}
.description {
font-size: 1.5rem;
margin-bottom: 20px;
text-align: center;
}
.alert {
padding: 20px;
margin-bottom: 20px;
border-radius: 5px;
}
.alert-success {
background-color: var(--light-primary-color);
color: var(--dark-primary-color);
}
.alert-warning {
background-color: var(--link-color);
color: var(--dark-primary-color);
}
.message {
font-size: 1.5rem;
margin: 0;
text-align: center;
}
.btn {
display: inline-block;
padding: 10px 20px !important;
border-radius: 5px;
text-decoration: none;
color: var(--text-color);
background-color: var(--link-color);
transition: background-color 0.2s ease-in-out;
}
.btn:hover {
background-color: var(--light-secondary-color);
}
.btn-primary {
background-color: var(--link-color);
}
.btn-danger {
background-color: var(--danger-color);
}
.btn-danger:hover {
background-color: var(--danger-color-hover);
}
.btn-primary:hover {
background-color: var(--light-secondary-color);
}
.flex-col-container {
display: flex;
flex-direction: column;
align-items: center;
}
.products-table {
width: 100%;
border-collapse: collapse;
margin-bottom: 20px;
}
.products-table th,
.products-table td {
padding: 10px;
text-align: left;
vertical-align: top;
border: 1px solid #ccc;
}
.products-table th {
background-color: #ddd;
color: black;
font-weight: bold;
}
.form-group {
margin-bottom: 20px;
}
.form-control {
display: block;
width: 100%;
padding: 10px;
border-radius: 4px;
border: 1px solid #ccc;
box-sizing: border-box;
margin-bottom: 10px;
}
label {
display: block;
margin-bottom: 5px;
font-weight: bold;
}
input[type="text"],
input[type="number"],
textarea {
display: block;
width: 100%;
padding: 10px;
border-radius: 4px;
border: 1px solid #ccc;
box-sizing: border-box;
margin-bottom: 10px;
}
button[type="submit"] {
float: right;
color: inherit;
border: none;
font: inherit;
cursor: pointer;
outline: inherit;
}
.flex-row-container {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
}
.card {
width: 100%;
margin-bottom: 20px;
border-radius: 5px;
box-shadow: 0px 4px 8px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.card-body {
padding: 20px;
}
.card-title {
font-size: 24px;
margin-bottom: 10px;
}
.card-subtitle {
font-size: 18px;
margin-bottom: 10px;
}
.card-text {
font-size: 16px;
margin-bottom: 20px;
}
.action {
display: flex;
justify-content: flex-start;
align-items: center;
align-self: flex-end;
}
.action button {
margin-left: 10px;
}
.alert-error {
background-color: var(--danger-color);
color: var(--text-color);
}

View File

@ -0,0 +1,21 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title><%= title %></title>
<link
href="https://fonts.googleapis.com/css?family=Poppins&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="/styles/main.css" type="text/css" />
</head>
<body>
<nav>
<ul>
<li><a href="/">Home</a></li>
<li><a href="/products">Products</a></li>
</ul>
</nav>
<main><%- body%></main>
</body>
</html>

View File

@ -29,25 +29,39 @@ describe("Testing application configuration", () => {
it("Should have the necessary development packages", (done) => {
expect(
packages.devDependencies,
`The package "cross-env" was not found in the devDependencies object. Install the package by running this command "npm i cross-env --save-dev"`,
options
).toHaveProperty("cross-env");
expect(
packages.devDependencies,
`The package "jest" was not found in the devDependencies object. Install the package by running this command "npm i jest --save-dev"`,
options
).toHaveProperty("jest");
expect(
packages.devDependencies,
`The package "nodemon" was not found in the devDependencies object. Install the package by running this command "npm i nodemon --save-dev"`,
options
).toHaveProperty("nodemon");
expect(
packages.devDependencies,
`The package "supertest" was not found in the devDependencies object. Install the package by running this command "npm i supertest --save-dev"`,
options
).toHaveProperty("supertest");
// expect(
// packages.devDependencies,
// ).toHaveProperty("jest-image-snapshot");
// expect(
// packages.devDependencies,
// ).toHaveProperty("jest-expect-message");
// expect(
// packages.devDependencies,
// ).toHaveProperty("puppeteer");
expect(
packages.devDependencies,
`The package "jest-image-snapshot" was not found in the devDependencies object. Install the package by running this command "npm i jest-image-snapshot --save-dev"`,
options
).toHaveProperty("jest-image-snapshot");
expect(
packages.devDependencies,
`The package "jest-expect-message" was not found in the devDependencies object. Install the package by running this command "npm i jest-expect-message --save-dev"`,
options
).toHaveProperty("jest-expect-message");
expect(
packages.devDependencies,
`The package "puppeteer" was not found in the devDependencies object. Install the package by running this command "npm i puppeteer --save-dev"`,
options
).toHaveProperty("puppeteer");
done();
});
// Testing the package.json file for the necessary production packages

Some files were not shown because too many files have changed in this diff Show More