This commit is contained in:
Billiefi 2025-05-06 10:25:09 +07:00
parent 57cd1518e1
commit 0f48ed400f
54 changed files with 4475 additions and 1312 deletions

View File

@ -15,13 +15,13 @@ public function index(Request $request)
$user = $request->user();
$projects = Project::skip(0)->take(3)->get();
if ($request->ajax()) {
$data = DB::connection('nodejsDB')->table('projects')
$data = DB::table('projects')
->select('projects.id', 'projects.title', DB::raw('COUNT(submissions.id) as submission_count'))
->leftJoin('submissions', function ($join) use ($user) {
$join->on('projects.id', '=', 'submissions.project_id')
->where('submissions.user_id', '=', $user->id);
})
->groupBy('projects.id');
->groupBy('projects.id', 'projects.title');
return Datatables::of($data)

View File

@ -16,7 +16,7 @@ class ProfileController extends Controller
*/
public function edit(Request $request): View
{
return view('profile.edit', [
return view('nodejs.profile.edit', [
'user' => $request->user(),
]);
}

View File

@ -32,9 +32,7 @@ public function showPDF(Request $request)
{
if ($request->ajax()) {
if ($request->id) {
$mediaModel = new Media();
$mediaModel->setConnection('nodejsDB');
$media = $mediaModel->find($request->id);
$media = Media::find($request->id);
if ($media) {
$path = $media->getUrl();
return response()->json($path, 200);
@ -60,7 +58,7 @@ public function download(Request $request, $project_id)
return response()->json($zipMedia->getUrl(), 200);
} else {
$guides = $project->getMedia('project_guides');
$tempDir = storage_path('app/public/assets/nodejs/projects/' . $project->title . '/zips');
$tempDir = storage_path('app/public/assets/projects/' . $project->title . '/zips');
if (!is_dir($tempDir)) mkdir($tempDir);
foreach ($guides as $guide) {
$path = $guide->getPath();
@ -70,7 +68,7 @@ public function download(Request $request, $project_id)
$zipPath = $tempDir . '/guides.zip';
$zip = new ZipArchive;
if ($zip->open($zipPath, ZipArchive::CREATE) === TRUE) {
$files = Storage::files('public/assets/nodejs/projects/' . $project->title . '/zips');
$files = Storage::files('public/assets/projects/' . $project->title . '/zips');
foreach ($files as $file) {
$zip->addFile(storage_path('app/' . $file), basename($file));
}
@ -81,7 +79,7 @@ public function download(Request $request, $project_id)
} else {
throw new Exception('Failed to create zip archive');
}
$media = $project->addMedia($zipPath)->toMediaCollection('project_zips', 'nodejs_public_projects_files');
$media = $project->addMedia($zipPath)->toMediaCollection('project_zips', 'public_projects_files');
return response()->json($media->getUrl(), 200);
}
break;
@ -91,7 +89,7 @@ public function download(Request $request, $project_id)
return response()->json($zipMedia->getUrl(), 200);
} else {
$supplements = $project->getMedia('project_supplements');
$tempDir = storage_path('app/public/assets/nodejs/projects/' . $project->title . '/zips');
$tempDir = storage_path('app/public/assets/projects/' . $project->title . '/zips');
if (!is_dir($tempDir)) mkdir($tempDir);
foreach ($supplements as $supplement) {
$path = $supplement->getPath();
@ -101,7 +99,7 @@ public function download(Request $request, $project_id)
$zipPath = $tempDir . '/supplements.zip';
$zip = new ZipArchive;
if ($zip->open($zipPath, ZipArchive::CREATE) === TRUE) {
$files = Storage::files('public/assets/nodejs/projects/' . $project->title . '/zips');
$files = Storage::files('public/assets/projects/' . $project->title . '/zips');
foreach ($files as $file) {
$zip->addFile(storage_path('app/' . $file), basename($file));
}
@ -112,7 +110,7 @@ public function download(Request $request, $project_id)
} else {
throw new Exception('Failed to create zip archive');
}
$media = $project->addMedia($zipPath)->toMediaCollection('project_zips', 'nodejs_public_projects_files');
$media = $project->addMedia($zipPath)->toMediaCollection('project_zips', 'public_projects_files');
return response()->json($media->getUrl(), 200);
}
break;
@ -125,7 +123,7 @@ public function download(Request $request, $project_id)
$tests_web = $project->getMedia('project_tests_web');
$tests_images = $project->getMedia('project_tests_images');
$tempDir = storage_path('app/public/assets/nodejs/projects/' . $project->title . '/zips');
$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');
@ -154,15 +152,15 @@ public function download(Request $request, $project_id)
$zip->addEmptyDir('api');
$zip->addEmptyDir('web');
$zip->addEmptyDir('web/images');
$api_files = Storage::files('public/assets/nodejs/projects/' . $project->title . '/zips/tests/api');
$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));
}
$api_files = Storage::files('public/assets/nodejs/projects/' . $project->title . '/zips/tests/web');
$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/nodejs/projects/' . $project->title . '/zips/tests/web/images');
$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));
}
@ -172,7 +170,7 @@ public function download(Request $request, $project_id)
} else {
throw new Exception('Failed to create zip archive');
}
$media = $project->addMedia($zipPath)->toMediaCollection('project_zips', 'nodejs_public_projects_files');
$media = $project->addMedia($zipPath)->toMediaCollection('project_zips', 'public_projects_files');
return response()->json($media->getUrl(), 200);
}
break;

View File

@ -1,4 +1,4 @@
.<?php
<?php
namespace App\Http\Controllers\NodeJS\Student;
@ -32,17 +32,15 @@ public function index(Request $request)
$user = $request->user();
$projects = Project::all();
if ($request->ajax()) {
$data = DB::connection('nodejsDB')->table('projects')
$data = DB::table('projects')
->select(
'projects.id',
'projects.title',
// DB::raw('(SELECT COUNT(DISTINCT submissions.id) FROM submissions WHERE submissions.project_id = projects.id AND submissions.user_id = ?) as submission_count'),
DB::raw('(SELECT COUNT(*) FROM submission_histories INNER JOIN submissions ON submissions.id = submission_histories.submission_id WHERE submissions.project_id = projects.id AND submissions.user_id = ?) as attempts_count'),
DB::raw('(SELECT status FROM submissions WHERE submissions.project_id = projects.id AND submissions.user_id = ? ORDER BY id DESC LIMIT 1) as submission_status')
)
->groupBy('projects.id', 'projects.title')
->setBindings([
// $user->id,
$user->id,
$user->id
]);
@ -50,7 +48,7 @@ public function index(Request $request)
return DataTables::of($data)
->addIndexColumn()
->addColumn('title', function ($row) {
$title_button = '<a href="/nodejs/submissions/project/' . $row->id . '" class="underline text-secondary">' . $row->title . '</a>';
$title_button = '<a href="submissions/project/' . $row->id . '" class="underline text-secondary">' . $row->title . '</a>';
return $title_button;
})
->addColumn('submission_status', function ($row) {
@ -64,29 +62,29 @@ public function index(Request $request)
$submission = Submission::where('project_id', $row->id)->where('user_id', $user->id)->orderBy('id', 'DESC')->first();
$buttons = '
<div class="relative" x-data="{ open: false }" @click.outside="open = false" @close.stop="open = false">
<div @click="open = ! open">
<button
class="flex items-center text-sm font-medium text-gray-900 hover:text-gray-500 dark:text-white dark:hover:text-gray-300 hover:underline">
<svg class="ml-1 h-5 w-5 text-gray-500 dark:text-gray-400"
xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"
aria-hidden="true">
<g id="Menu / Menu_Alt_02">
<path id="Vector" d="M11 17H19M5 12H19M11 7H19" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</g>
</svg>
</button>
</div>
<div x-show="open"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="transform opacity-0 scale-95"
x-transition:enter-end="transform opacity-100 scale-100"
x-transition:leave="transition ease-in duration-75"
x-transition:leave-start="transform opacity-100 scale-100"
x-transition:leave-end="transform opacity-0 scale-95"
class="absolute z-50 mt-2 w-48 rounded-md shadow-lg origin-top"
style="display: none;"
@click="open = false">
<div @click="open = ! open">
<button
class="flex items-center text-sm font-medium text-gray-900 hover:text-gray-500 dark:text-white dark:hover:text-gray-300 hover:underline">
<svg class="ml-1 h-5 w-5 text-gray-500 dark:text-gray-400"
xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"
aria-hidden="true">
<g id="Menu / Menu_Alt_02">
<path id="Vector" d="M11 17H19M5 12H19M11 7H19" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</g>
</svg>
</button>
</div>
<div x-show="open"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="transform opacity-0 scale-95"
x-transition:enter-end="transform opacity-100 scale-100"
x-transition:leave="transition ease-in duration-75"
x-transition:leave-start="transform opacity-100 scale-100"
x-transition:leave-end="transform opacity-0 scale-95"
class="absolute z-50 mt-2 w-48 rounded-md shadow-lg origin-top"
style="display: none;"
@click="open = false">
<div class="rounded-md ring-1 ring-black ring-opacity-5 py-1 bg-white dark:bg-gray-700">
';
if ($submission !== null) {
@ -131,7 +129,7 @@ public function upload(Request $request, $project_id)
$file = $request->file('folder_path');
$file_name = $file->getClientOriginalName();
$folder_path = 'public/nodejs/tmp/submissions/' . $request->user()->id . '/' . $project_title;
$folder_path = 'public/tmp/submissions/' . $request->user()->id . '/' . $project_title;
$file->storeAs($folder_path, $file_name);
TemporaryFile::create([
@ -149,7 +147,7 @@ public function submit(Request $request)
try {
$request->validate([
'project_id' => 'required|exists:nodejsDB.projects,id',
'project_id' => 'required|exists:projects,id',
'folder_path' => 'required_without:github_url',
'github_url' => 'required_without:folder_path',
]);
@ -171,7 +169,7 @@ public function submit(Request $request)
if ($temporary_file) {
$path = storage_path('app/' . $request->folder_path . '/' . $temporary_file->file_name);
$submission->addMedia($path)->toMediaCollection('submissions', 'nodejs_public_submissions_files');
$submission->addMedia($path)->toMediaCollection('submissions', 'public_submissions_files');
if ($this->is_dir_empty(storage_path('app/' . $request->folder_path))) {
rmdir(storage_path('app/' . $request->folder_path));
}
@ -337,6 +335,14 @@ public function process(Request $request)
break;
}
}
// Revalidate status before returning response to ensure consistency
if ($isNotHistory && $submission->status === Submission::$PROCESSING) {
// Force a fresh check of status based on current step results
$submission->getCurrentExecutionStep();
$completion_percentage = round($submission->getTotalCompletedSteps() / $submission->getTotalSteps() * 100);
}
return $this->returnSubmissionResponse(
$isNotHistory ? 'Step ' . $step->executionStep->name . ' is ' . $submission->results->{$step->executionStep->name}->status : "History",
$submission->status,
@ -345,6 +351,33 @@ public function process(Request $request)
$completion_percentage
);
}
// If we get here, check one more time if submission should be marked as completed
if ($isNotHistory && $submission->status === Submission::$PROCESSING) {
$allStepsComplete = true;
$anyStepsFailed = false;
foreach ($submission->getExecutionSteps() as $execStep) {
$stepName = $execStep->executionStep->name;
if (
!isset($submission->results->$stepName) ||
$submission->results->$stepName->status === Submission::$PENDING ||
$submission->results->$stepName->status === Submission::$PROCESSING
) {
$allStepsComplete = false;
break;
}
if ($submission->results->$stepName->status === Submission::$FAILED) {
$anyStepsFailed = true;
}
}
if ($allStepsComplete) {
$submission->updateStatus($anyStepsFailed ? Submission::$FAILED : Submission::$COMPLETED);
}
}
return $this->returnSubmissionResponse(
($isNotHistory ? 'Submission is processing meanwhile there is no step to execute' : "History"),
$submission->status,
@ -399,7 +432,10 @@ public function refresh(Request $request)
}
// Delete temp directory
foreach ($commands as $command) {
$process = new Process($command, null, null, null, 120);
$env = [
'PATH' => config('app.process_path') . ':' . getenv('PATH'),
];
$process = new Process($command, null, $env, null, 120);
$process->run();
if ($process->isSuccessful()) {
Log::info('Command ' . implode(" ", $command) . ' is successful');
@ -429,7 +465,7 @@ public function refresh(Request $request)
private function getTempDir($submission)
{
return storage_path('app/public/nodejs/tmp/submissions/' . $submission->user_id . '/' . $submission->project->title . '/' . $submission->id);
return storage_path('app/public/tmp/submissions/' . $submission->user_id . '/' . $submission->project->title . '/' . $submission->id);
}
private function is_dir_empty($dir)
@ -502,33 +538,51 @@ private function lunchReplacePackageJsonJob($submission, $packageJson, $tempDir,
private function lunchCopyTestsFolderJob($submission, $tempDir, $step)
{
$testsDir = [
'testsDirApi' => $submission->project->getMedia('project_tests_api'),
'testsDirWeb' => $submission->project->getMedia('project_tests_web'),
'testsDirImage' => $submission->project->getMedia('project_tests_images'),
];
// command 1: [1]cp [2]-r [3]{{testsDir}} [4]{{tempDir}}
$testFiles = $step->variables;
$commands = $step->executionStep->commands;
$step_variables = $step->variables;
$values = ['{{testsDir}}' => $testsDir, '{{tempDir}}' => $tempDir];
$commands = $this->replaceCommandArraysWithValues($step_variables, $values, $step);
$commandsArray = [];
foreach ($testsDir['testsDirApi'] as $key => $value) {
$commands[2] = $value->getPath();
$commands[3] = $tempDir . '/tests/api';
array_push($commandsArray, $commands);
$testDirPath = $tempDir . '/tests';
if (!is_dir($testDirPath)) {
mkdir($testDirPath, 0777, true);
}
foreach ($testsDir['testsDirWeb'] as $key => $value) {
$commands[2] = $value->getPath();
$commands[3] = $tempDir . '/tests/web';
array_push($commandsArray, $commands);
foreach ($testFiles as $testFile) {
// Parse {{sourceFile}}=media:filename:subfolder
$parts = explode("=", $testFile);
if (isset($parts[1])) {
// media:filename:subfolder -> [media, filename, subfolder]
$fileConfig = explode(":", $parts[1]);
$mediaCollection = isset($fileConfig[0]) ? $fileConfig[0] : 'tests';
$fileName = isset($fileConfig[1]) ? $fileConfig[1] : '';
$subFolder = isset($fileConfig[2]) ? $fileConfig[2] : '';
$mediaItem = $submission->project->getMedia('project_tests' . ($mediaCollection != 'tests' ? "_$mediaCollection" : ""))
->where('file_name', $fileName)
->first();
if ($mediaItem) {
$destDir = $testDirPath;
if (!empty($subFolder)) {
$destDir .= '/' . $subFolder;
if (!is_dir($destDir)) {
mkdir($destDir, 0777, true);
}
}
$copyCommand = $commands;
$copyCommand[2] = $mediaItem->getPath();
$copyCommand[3] = $destDir . '/' . basename($fileName);
$commandsArray[] = $copyCommand;
} else {
Log::warning("Test file not found: {$fileName} in collection project_tests_{$mediaCollection}");
}
}
}
foreach ($testsDir['testsDirImage'] as $key => $value) {
$commands[2] = $value->getPath();
$commands[3] = $tempDir . '/tests/web/images';
array_push($commandsArray, $commands);
}
dispatch(new CopyTestsFolder($submission, $testsDir, $tempDir, $commandsArray))->onQueue(ExecutionStep::$COPY_TESTS_FOLDER);
dispatch(new CopyTestsFolder($submission, null, $tempDir, $commandsArray))->onQueue(ExecutionStep::$COPY_TESTS_FOLDER);
}
private function lunchNpmInstallJob($submission, $tempDir, $step)
@ -543,7 +597,7 @@ private function lunchNpmInstallJob($submission, $tempDir, $step)
private function lunchNpmRunStartJob($submission, $tempDir, $step)
{
$commands = $step->executionStep->commands;
dispatch_sync(new NpmRunStart($submission, $tempDir, $commands));
dispatch(new NpmRunStart($submission, $tempDir, $commands));
}
private function lunchNpmRunTestsJob($submission, $tempDir, $step)
@ -653,7 +707,7 @@ public function changeSourceCode($submission_id)
$user = Auth::user();
$submission = Submission::where('id', $submission_id)->where('user_id', $user->id)->first();
if ($submission) {
return view('nodejs.submissions.change_source_code', compact('submission'));
return view('submissions.change_source_code', compact('submission'));
}
return redirect()->route('submissions');
}
@ -662,7 +716,7 @@ public function update(Request $request)
{
try {
$request->validate([
'submission_id' => 'required|exists:nodejsDB.submissions,id',
'submission_id' => 'required|exists:submissions,id',
'folder_path' => 'required_without:github_url',
'github_url' => 'required_without:folder_path',
]);
@ -693,7 +747,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', 'nodejs_public_submissions_files');
$submission->addMedia($path)->toMediaCollection('submissions', 'public_submissions_files');
if ($this->is_dir_empty(storage_path('app/' . $request->folder_path))) {
rmdir(storage_path('app/' . $request->folder_path));
}

View File

@ -12,6 +12,7 @@
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use Symfony\Component\Process\Process;
use Illuminate\Support\Facades\File;
class CloneRepository implements ShouldQueue
{
@ -41,8 +42,10 @@ public function handle(): void
Log::info("Cloning repo {$this->repoUrl} into {$this->tempDir}");
$this->updateSubmissionStatus($submission, Submission::$PROCESSING, "Cloning repo {$this->repoUrl}");
try {
$this->prepareTempDirectory();
// processing
$process = new Process($this->command);
$process = new Process($this->command, null, null, null, 500);
$process->start();
$process_pid = $process->getPid();
$process->wait();
@ -71,4 +74,15 @@ private function updateSubmissionStatus(Submission $submission, string $status,
if ($status != Submission::$PROCESSING) $submission->updateOneResult($stepName, $status, $output);
if ($status != Submission::$COMPLETED) $submission->updateStatus($status);
}
private function prepareTempDirectory(): void
{
if (File::exists($this->tempDir)) {
File::deleteDirectory($this->tempDir);
}
File::ensureDirectoryExists(dirname($this->tempDir), 0755, true);
Log::info("Created temp directory {$this->tempDir}");
}
}

View File

@ -41,36 +41,36 @@ public function handle(): void
Log::info("Copying tests folder to {$this->tempDir}");
$this->updateSubmissionStatus($submission, Submission::$PROCESSING, "Copying tests folder");
try {
// processing
if (is_dir($this->tempDir . '/tests')) {
Log::info("Removing old tests folder from {$this->tempDir}");
Process::fromShellCommandline("rm -rf {$this->tempDir}/tests")->run();
// Ensure tests directory exists
if (!is_dir($this->tempDir . '/tests')) {
mkdir($this->tempDir . '/tests', 0777, true);
}
mkdir($this->tempDir . '/tests', 0777, true);
mkdir($this->tempDir . '/tests/api', 0777, true);
mkdir($this->tempDir . '/tests/web', 0777, true);
mkdir($this->tempDir . '/tests/web/images', 0777, true);
foreach ($this->command as $key => $value) {
$process = new Process($value);
// Execute all commands to copy test files
foreach ($this->command as $command) {
$process = new Process($command);
$process->start();
$process_pid = $process->getPid();
$process->wait();
if ($process->isSuccessful()) {
Log::info("Copied tests {$value[2]} folder to {$value[3]}");
Log::info("Copied test file {$command[2]} to {$command[3]}");
} else {
Log::error("Failed to copying tests {$value[2]} folder to {$value[3]}");
$this->updateSubmissionStatus($submission, Submission::$FAILED, "Failed to copying tests folder");
Log::error("Failed to copy test file {$command[2]} to {$command[3]}");
Log::error("Error: " . $process->getErrorOutput());
$this->updateSubmissionStatus($submission, Submission::$FAILED, "Failed to copy test files: " . $process->getErrorOutput());
Process::fromShellCommandline("rm -rf {$this->tempDir}")->run();
Process::fromShellCommandline('kill ' . $process_pid)->run();
throw new \Exception($process->getErrorOutput());
}
}
// completed
Log::info("Copied tests folder to {$this->tempDir}");
$this->updateSubmissionStatus($submission, Submission::$COMPLETED, "Copied tests folder");
Log::info("Copied all test files to {$this->tempDir}");
$this->updateSubmissionStatus($submission, Submission::$COMPLETED, "Copied test files successfully");
} catch (\Throwable $th) {
Log::error("Failed to copying tests folder to {$this->tempDir} " . $th->getMessage());
$this->updateSubmissionStatus($submission, Submission::$FAILED, "Failed to copying tests folder");
Log::error("Failed to copy test files to {$this->tempDir}: " . $th->getMessage());
$this->updateSubmissionStatus($submission, Submission::$FAILED, "Failed to copy test files: " . $th->getMessage());
Process::fromShellCommandline("rm -rf {$this->tempDir}")->run();
}
}

View File

@ -41,7 +41,10 @@ public function handle(): void
try {
// processing
// check if the module is already exist within the assets folder
$process = new Process($this->command, $this->tempDir, null, null, 120);
$env = [
'PATH' => config('app.process_path') . ':' . getenv('PATH'),
];
$process = new Process($this->command, $this->tempDir, $env, null, 120);
$process->start();
$process_pid = $process->getPid();
$process->wait();

View File

@ -36,12 +36,14 @@ public function __construct($submission, $tempDir, $command)
*/
public function handle(): void
{
set_time_limit(60);
$submission = $this->submission;
$tempDir = $this->tempDir;
$command = $this->command;
Log::info("NPM run start is processing in folder {$this->tempDir}");
Log::info("NPM run will run on folder {$this->tempDir}");
$this->updateSubmissionStatus($submission, Submission::$PROCESSING, "NPM run start is processing");
// Change port number in .env file
$port = $this->getAvailablePort();
@ -49,6 +51,7 @@ public function handle(): void
$this->updateSubmissionStatus($submission, Submission::$FAILED, "Failed to find an available port for the project");
return;
}
Log::info("NPM run start is processing on port $port");
$submission->updatePort($port);
// Change port number in .env file
$envPath = "$tempDir/.env";
@ -60,21 +63,23 @@ public function handle(): void
// Run NPM start command
try {
$process = new Process($command, $tempDir, null, null, null);
$env = [
'PATH' => config('app.process_path') . ':' . getenv('PATH'),
];
$process = new Process($command, $tempDir, $env, null, null);
$process->start();
$process->waitUntil(function ($type, $output) use ($port) {
return strpos($output, "Server started on port $port") !== false || strpos($output, "MongoNetworkError") !== false;
return strpos($output, "$port") !== false || strpos($output, "MongoNetworkError") !== false;
}, 60000); // Wait for 60 seconds
if (strpos($process->getOutput(), "Server started on port $port") !== false) {
if (strpos($process->getOutput(), "$port") !== false) {
Log::info("NPM run start is completed in folder {$tempDir} the application is running on port $port");
$this->updateSubmissionStatus($submission, Submission::$COMPLETED, $process->getOutput());
$process->wait();
} else {
Log::error("Failed to NPM run start in folder {$tempDir} due to error " . $process->getOutput());
$this->updateSubmissionStatus($submission, Submission::$FAILED, "Failed to start application on port $port");
Process::fromShellCommandline("npx kill-port $port")->run();
}
Process::fromShellCommandline("npx kill-port $port")->run();
} catch (ProcessTimedOutException $th) {
$process->stop();
Log::error("Failed to NPM run start in folder {$tempDir} due to timeout " . $process->getOutput());

View File

@ -47,19 +47,69 @@ public function handle(): void
Log::info("Running {$command_string} in folder {$this->tempDir}");
$this->updateSubmissionTestsResultsStatus($command_string, $submission, Submission::$PROCESSING, "Running");
usleep(100000);
$process = new Process($command, $this->tempDir, null, null, 120);
$process->start();
$process_pid = $process->getPid();
$process->wait();
$env = [
'PATH' => config('app.process_path') . ':' . getenv('PATH'),
];
$process = new Process($command, $this->tempDir, $env, null, 120);
$process->setTty(false);
$process->setPty(false);
$output = '';
$errorOutput = '';
$process->run(function ($type, $buffer) use (&$output, &$errorOutput) {
if (Process::OUT === $type) {
$output .= $buffer;
} else {
$errorOutput .= $buffer;
}
});
$fullOutput = trim($output . "\n" . $errorOutput);
$passedTests = null;
$totalTests = null;
$failedTests = null;
// Handle format: "Tests: X failed, Y passed, Z total"
if (preg_match('/Tests:\s+(\d+)\s+failed,\s+(\d+)\s+passed,\s+(\d+)\s+total/', $fullOutput, $matches)) {
$failedTests = (int)$matches[1];
$passedTests = (int)$matches[2];
$totalTests = (int)$matches[3];
Log::info("Extracted test metrics: $failedTests failed, $passedTests passed, $totalTests total");
}
// Handle format: "Tests: X passed, Y total"
else if (preg_match('/Tests:\s+(\d+)\s+passed,\s+(\d+)\s+total/', $fullOutput, $matches)) {
$passedTests = (int)$matches[1];
$totalTests = (int)$matches[2];
$failedTests = $totalTests - $passedTests;
Log::info("Extracted test metrics: $passedTests passed, $totalTests total");
}
if ($process->isSuccessful()) {
$pass_all[$key] = true;
Log::info("{$command_string} in folder {$this->tempDir}");
$this->updateSubmissionTestsResultsStatus($command_string, $submission, Submission::$COMPLETED, "Completed");
Log::info("{$command_string} completed in folder {$this->tempDir}");
$this->updateSubmissionTestsResultsStatus(
$command_string,
$submission,
Submission::$COMPLETED,
$fullOutput,
$passedTests,
$totalTests,
$failedTests
);
} else {
$pass_all[$key] = false;
Log::error("Failed to NPM run test {$command_string} " . $process->getErrorOutput());
$this->updateSubmissionTestsResultsStatus($command_string, $submission, Submission::$FAILED, $process->getErrorOutput());
Process::fromShellCommandline('kill ' . $process_pid)->run();
Log::error("Failed to NPM run test {$command_string}");
$this->updateSubmissionTestsResultsStatus(
$command_string,
$submission,
Submission::$FAILED,
$fullOutput,
$passedTests,
$totalTests,
$failedTests
);
}
}
if (in_array(false, $pass_all) == false) {
@ -77,10 +127,17 @@ public function handle(): void
}
}
private function updateSubmissionTestsResultsStatus($testName, Submission $submission, string $status, string $output): void
{
private function updateSubmissionTestsResultsStatus(
$testName,
Submission $submission,
string $status,
string $output,
?int $passedTests = null,
?int $totalTests = null,
?int $failedTests = null
): void {
$stepName = ExecutionStep::$NPM_RUN_TESTS;
$submission->updateOneTestResult($stepName, $testName, $status, $output);
$submission->updateOneTestResult($stepName, $testName, $status, $output, $passedTests, $totalTests, $failedTests);
if ($status != Submission::$COMPLETED) $submission->updateStatus($status);
}

View File

@ -31,19 +31,26 @@ public function __construct($submission, $zipFileDir, $tempDir, $command)
$this->tempDir = $tempDir;
$this->command = $command;
}
/**
* Execute the job.
*/
public function handle(): void
{
$submission = $this->submission;
Log::info("Unzipping {$this->zipFileDir} into {$this->tempDir}");
$this->updateSubmissionStatus($submission, Submission::$PROCESSING, "Unzipping submitted folder");
try {
if (!file_exists($this->tempDir)) mkdir($this->tempDir, 0777, true);
// processing
$process = new Process($this->command);
$this->prepareTempDirectory();
$process = new Process(
$this->command,
null,
$this->getEnvironment(),
null,
300
);
$process->start();
$process_pid = $process->getPid();
$process->wait();
@ -51,23 +58,55 @@ public function handle(): void
Log::info("Unzipped {$this->zipFileDir} into {$this->tempDir}");
$this->updateSubmissionStatus($submission, Submission::$COMPLETED, "Unzipped submitted folder");
} else {
Log::error("Failed to unzip {$this->zipFileDir} " . $process->getErrorOutput());
$this->updateSubmissionStatus($submission, Submission::$FAILED, "Failed to unzip submitted folder");
Process::fromShellCommandline('kill ' . $process_pid)->run();
Process::fromShellCommandline("rm -rf {$this->tempDir}")->run();
$error = "Failed to unzip: " . $process->getErrorOutput() . "\n" . $process->getOutput();
Log::error($error);
$this->cleanup();
$this->updateSubmissionStatus($submission, Submission::$FAILED, $error);
}
} catch (\Throwable $th) {
// failed
Log::error("Failed to unzip {$this->zipFileDir} " . $th->getMessage());
$this->updateSubmissionStatus($submission, Submission::$FAILED, "Failed tp unzip submitted folder");
// Process::fromShellCommandline("rm -rf {$this->tempDir}")->run();
$error = "Failed to unzip {$this->zipFileDir} " . $th->getMessage();
Log::error($error);
$this->cleanup();
$this->updateSubmissionStatus($submission, Submission::$FAILED, $error);
Process::fromShellCommandline('kill ' . $process_pid)->run();
Process::fromShellCommandline("rm -rf {$this->tempDir}")->run();
}
}
protected function prepareTempDirectory(): void
{
File::ensureDirectoryExists($this->tempDir, 0755);
File::cleanDirectory($this->tempDir);
}
protected function getEnvironment(): array
{
return [
'PATH' => '/usr/local/bin:/usr/bin:/bin:' . getenv('PATH'),
'HOME' => getenv('HOME') ?: '/tmp',
];
}
protected function cleanup(): void
{
try {
if (File::exists($this->tempDir)) {
File::deleteDirectory($this->tempDir);
}
} catch (\Throwable $th) {
Log::error("Cleanup failed: " . $th->getMessage());
}
}
private function updateSubmissionStatus(Submission $submission, string $status, string $output): void
{
$stepName = ExecutionStep::$UNZIP_ZIP_FILES;
if ($status != Submission::$PROCESSING) $submission->updateOneResult($stepName, $status, $output);
if ($status != Submission::$COMPLETED) $submission->updateStatus($status);
if ($status !== Submission::$PROCESSING) {
$submission->updateOneResult($stepName, $status, $output);
}
if ($status !== Submission::$COMPLETED) {
$submission->updateStatus($status);
}
}
}

View File

@ -9,8 +9,6 @@ class ExecutionStep extends Model
{
use HasFactory;
protected $connection = 'nodejsDB';
protected $fillable = [
'name',
'commands',

View File

@ -11,8 +11,6 @@ class Project extends Model implements HasMedia
{
use HasFactory, InteractsWithMedia;
protected $connection = 'nodejsDB';
protected $fillable = [
'title',
'description',

View File

@ -9,8 +9,6 @@ class ProjectExecutionStep extends Model
{
use HasFactory;
protected $connection = 'nodejsDB';
protected $fillable = [
'project_id',
'execution_step_id',

View File

@ -3,14 +3,12 @@
namespace App\Models\NodeJS;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Model;;
class ProjectsDefaultFileStructure extends Model
{
use HasFactory;
protected $connection = 'nodejsDB';
protected $fillable = [
'project_id',
'structure',

View File

@ -10,9 +10,6 @@
class Submission extends Model implements HasMedia
{
use HasFactory, InteractsWithMedia;
protected $connection = 'nodejsDB';
static $types = ['file', 'url'];
static $statues = ['pending', 'processing', 'completed', 'failed'];
static $FILE = 'file';
@ -158,11 +155,25 @@ public function updateOneResult($step_name, $status, $output)
$this->updateResults($results);
}
public function updateOneTestResult($step_name, $test_name, $status, $output)
{
public function updateOneTestResult(
$step_name,
$test_name,
$status,
$output,
?int $passedTests = null,
?int $totalTests = null,
?int $failedTests = null
) {
$results = $this->results;
$results->$step_name->testResults->$test_name->status = $status;
$results->$step_name->testResults->$test_name->output = $output;
if ($passedTests !== null && $totalTests !== null) {
$results->$step_name->testResults->$test_name->passedTests = $passedTests;
$results->$step_name->testResults->$test_name->totalTests = $totalTests;
$results->$step_name->testResults->$test_name->failedTests = $failedTests;
}
$this->updateResults($results);
}
@ -192,8 +203,12 @@ public function getCurrentExecutionStep($step_id = null)
if (!$results) {
return $current_step;
}
foreach ($steps as $step) {
if ($results->{$step->executionStep->name}?->status == self::$PROCESSING || $results->{$step->executionStep->name}?->status == self::$PENDING) {
if (
$results->{$step->executionStep->name}?->status == self::$PROCESSING ||
$results->{$step->executionStep->name}?->status == self::$PENDING
) {
$current_step = $step;
break;
}
@ -201,16 +216,29 @@ public function getCurrentExecutionStep($step_id = null)
if (!$current_step) {
$have_failed_steps = false;
$all_completed = true;
foreach ($steps as $step) {
if ($results->{$step->executionStep->name}?->status == self::$FAILED) {
$have_failed_steps = true;
if (
!isset($results->{$step->executionStep->name}) ||
$results->{$step->executionStep->name}?->status == self::$PENDING ||
$results->{$step->executionStep->name}?->status == self::$PROCESSING
) {
$all_completed = false;
break;
}
if ($results->{$step->executionStep->name}?->status == self::$FAILED) {
$have_failed_steps = true;
}
}
if ($have_failed_steps) {
$this->updateStatus(self::$FAILED);
} else {
$this->updateStatus(self::$COMPLETED);
if ($all_completed) {
if ($have_failed_steps) {
$this->updateStatus(self::$FAILED);
} else {
$this->updateStatus(self::$COMPLETED);
}
}
}

View File

@ -8,9 +8,6 @@
class SubmissionHistory extends Model
{
use HasFactory;
protected $connection = 'nodejsDB';
static $types = ['file', 'url'];
static $statues = ['pending', 'processing', 'completed', 'failed'];
static $FILE = 'file';

View File

@ -9,8 +9,6 @@ class TemporaryFile extends Model
{
use HasFactory;
protected $connection = 'nodejsDB';
protected $fillable = [
'folder_path',
'file_name',

File diff suppressed because it is too large Load Diff

2
package-lock.json generated
View File

@ -1,5 +1,5 @@
{
"name": "iCLOP-V2",
"name": "iCLOP_V3",
"lockfileVersion": 3,
"requires": true,
"packages": {

View File

@ -0,0 +1,29 @@
{
"name": "restaurant-reservation",
"version": "1.0.0",
"description": "1. **Buat folder proyek** \r ```sh\r npm init -y",
"main": "app.js",
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js",
"praktikum1": "cross-env NODE_ENV=test jest -i test/praktikum1.test.js --testTimeout=20000",
"praktikum2": "cross-env NODE_ENV=test jest -i --verbose test/praktikum2Unit.test.js test/praktikum2Integration.test.js --testTimeout=20000",
"praktikum3": "cross-env NODE_ENV=test jest -i --verbose test/praktikum3Unit.test.js test/praktikum3Integration.test.js --testTimeout=20000"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"dotenv": "^16.4.7",
"express": "^4.21.2",
"mongodb": "^6.13.1",
"mongoose": "^8.10.1"
},
"devDependencies": {
"cross-env": "^7.0.3",
"fs-extra": "^11.3.0",
"jest": "^29.7.0",
"nodemon": "^3.1.9",
"supertest": "^7.0.0"
}
}

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=pilih_port
# ganti semua <> dengan kredensial Anda sendiri seperti yang diberikan oleh MongoDB Atlas (https://www.mongodb.com/cloud/atlas)
# disarankan untuk menggunakan database yang berbeda untuk pengujian, oleh karena itu ada akhiran -test pada MONGODB_URI_TEST
# pilih port yang belum digunakan (misalnya 5000)

View File

@ -0,0 +1,2 @@
node_modules
.env

View File

@ -0,0 +1,240 @@
const mongoose = require("mongoose");
const request = require("supertest");
const app = require("../../app");
const packages = require("../../package.json");
require("dotenv").config();
mongoose.set("strictQuery", true);
const options = {
showPrefix: false,
showMatcherMessage: true,
showStack: true,
};
beforeAll(async () => {
await connectDB().then(
async () => {
console.log("Database connected successfully");
},
(err) => {
console.log("There is problem while connecting database " + err);
}
);
});
describe("Testing application configuration", () => {
// Testing the package.json file for the necessary development packages
// the packages are cross-env, jest, nodemon, supertest, jest-image-snapshot, jest-expect-message, puppeteer
it("Should have the necessary development packages", (done) => {
expect(
packages.devDependencies,
).toHaveProperty("cross-env");
expect(
packages.devDependencies,
).toHaveProperty("jest");
expect(
packages.devDependencies,
).toHaveProperty("nodemon");
expect(
packages.devDependencies,
).toHaveProperty("supertest");
// expect(
// packages.devDependencies,
// ).toHaveProperty("jest-image-snapshot");
// expect(
// packages.devDependencies,
// ).toHaveProperty("jest-expect-message");
// expect(
// packages.devDependencies,
// ).toHaveProperty("puppeteer");
done();
});
// Testing the package.json file for the necessary production packages
// the packages are dotenv, ejs, express, express-ejs-layouts, mongoose, mongoose-slug-generator
it("should have the necessary production packages", (done) => {
expect(
packages.dependencies,
`The package "dotenv" was not found in the dependencies object. Install the package by running this command "npm i dotenv --save"`,
options
).toHaveProperty("dotenv");
expect(
packages.dependencies,
`The package "ejs" was not found in the dependencies object. Install the package by running this command "npm i ejs --save"`,
options
).toHaveProperty("ejs");
expect(
packages.dependencies,
`The package "express" was not found in the dependencies object. Install the package by running this command "npm i express --save"`,
options
).toHaveProperty("express");
expect(
packages.dependencies,
`The package "express-ejs-layouts" was not found in the dependencies object. Install the package by running this command "npm i express-ejs-layouts --save"`,
options
).toHaveProperty("express-ejs-layouts");
expect(
packages.dependencies,
`The package "mongoose" was not found in the dependencies object. Install the package by running this command "npm i mongoose --save"`,
options
).toHaveProperty("mongoose");
expect(
packages.dependencies,
`The package "mongoose-slug-generator" was not found in the dependencies object. Install the package by running this command "npm i mongoose-slug-generator --save"`,
options
).toHaveProperty("mongoose-slug-generator");
done();
});
// Testing the application name
// the application name should be "api-experiment"
it("should have the right name and packages", (done) => {
expect(
packages.name,
`The name provided "${packages.name}" is wrong. The application name should be "api-experiment", check the package.json file`,
options
).toBe("api-experiment");
done();
});
// Testing the application environment variables
// the application should have the following environment variables
// MONGODB_URI, MONGODB_URI_TEST, PORT
it("should have the right environment variables", (done) => {
expect(
process.env,
`The environment variable "MONGODB_URI" was not found. Check the .env file`,
options
).toHaveProperty("MONGODB_URI");
expect(
process.env,
`The environment variable "MONGODB_URI_TEST" was not found. Check the .env.test file`,
options
).toHaveProperty("MONGODB_URI_TEST");
expect(
process.env.MONGODB_URI !== process.env.MONGODB_URI_TEST,
`The environment variable "MONGODB_URI" and "MONGODB_URI_TEST" should not be the same. Check the .env`,
options
).toBeTruthy();
expect(
process.env,
`The environment variable "PORT" was not found. Check the .env file`,
options
).toHaveProperty("PORT");
expect(
process.env.NODE_ENV,
`The environment variable "NODE_ENV" was not found. Check the test script in the package.json file`
).toBe("test");
done();
});
// Testing the application connection to the database using the test environment
it("should have the right database connection", (done) => {
expect(
mongoose.connection.readyState,
`The application is not connected to the database. Check the correctness of the MONGODB_URI_TEST variable in the .env file or the connection to the internet`,
options
).toBe(1);
done();
});
// Testing the application configuration
it("should be using json format and express framework", (done) => {
let application_stack = [];
app._router.stack.forEach((element) => {
application_stack.push(element.name);
});
expect(
application_stack,
`The application is not using the json format. Check the app.js file`,
options
).toContain("query");
expect(
application_stack,
`The application is not using the express framework. Check the app.js file`,
options
).toContain("expressInit");
expect(
application_stack,
`The application is not using the json format. Check the app.js file`,
options
).toContain("jsonParser");
expect(
application_stack,
`The application is not using the urlencoded format. Check the app.js file`,
options
).toContain("urlencodedParser");
done();
});
});
// Testing the application testing route
describe("Testing GET /api/v1/test", () => {
// Testing the application testing route without request
it("should return alive", async () => {
const res = await request(app).get("/api/v1/test");
expect(
res.statusCode,
`The status code should be 200, but it is "${res.statusCode}", change the status code in the function that handles the GET /api/v1/test route`,
options
).toBe(200);
expect(
res.body,
`The response should contain the property "alive", but it does not, change the response in the function that handles the GET /api/v1/test route to return {alive: 'True'}`,
options
).toHaveProperty("alive");
expect(
res.body.alive,
`The response should be {alive: 'True'}, but it is "${res.body.alive}", change the response in the function that handles the GET /api/v1/test route to return {alive: 'True'}`,
options
).toBe("True");
expect(
res.req.method,
`The request method should be GET, but it is "${res.req.method}", change the request method in the function that handles the GET /api/v1/test route`,
options
).toBe("GET");
expect(
res.type,
`The response type should be application/json, but it is "${res.type}", change the response type in the function that handles the GET /api/v1/test route`
).toBe("application/json");
});
// Testing the application testing route with request
it("should return the same message", async () => {
const res = await request(app)
.get("/api/v1/test")
.send({ message: "Hello" });
expect(
res.statusCode,
`The status code should be 200, but it is "${res.statusCode}", change the status code in the function that handles the GET /api/v1/test route`,
options
).toBe(200);
expect(
res.body,
`The response should contain the property "message", but it does not, change the response in the function that handles the GET /api/v1/test route to return {message: req.body.message}`,
options
).toHaveProperty("message");
expect(
res.body.message,
`The response should be {message: 'Hello'}, but it is "${res.body.message}", change the response in the function that handles the GET /api/v1/test route to return {message: req.body.message}`,
options
).toBe("Hello");
const res2 = await request(app)
.get("/api/v1/test")
.send({ message: "Hello World" });
expect(
res2.body.message,
`The response should be {message: 'Hello World'}, but it is "${res2.body.message}", change the response in the function that handles the GET /api/v1/test route to return {message: req.body.message}`,
options
).toBe("Hello World");
});
});
afterAll(async () => {
await disconnectDB();
});
async function connectDB() {
return mongoose.connect(process.env.MONGODB_URI_TEST, {
useNewUrlParser: true,
useUnifiedTopology: true,
});
}
async function disconnectDB() {
await mongoose.connection.close();
}

View File

@ -0,0 +1,306 @@
const mongoose = require("mongoose");
const request = require("supertest");
const app = require("../../app");
const Product = require("../../models/product.model");
require("dotenv").config();
mongoose.set("strictQuery", true);
const options = {
showPrefix: false,
showMatcherMessage: true,
showStack: true,
};
beforeAll(async () => {
await connectDB(process.env.MONGODB_URI_TEST).then(
async () => {
console.log("Database connected successfully");
await createProducts();
},
(err) => {
console.log("There is problem while connecting database " + err);
}
);
});
describe("GET /api/v1/products", () => {
it("should return all products", async () => {
const res = await request(app).get("/api/v1/products");
expect(
res.statusCode,
`When calling GET /api/v1/products, the status code should be "200", but it was "${res.statusCode}", update your code to return 200`,
options
).toBe(200);
expect(
res.body.message,
`When calling GET /api/v1/products, the message should be "Products found", but it was "${res.body.message}", update your code to return "Products found"`,
options
).toBe("Products found");
expect(
res.body.products.length,
`When calling GET /api/v1/products, the products array should return more than 0 products, but it was "${res.body.products.length}", update your code to return more than 0 products`,
options
).toBeGreaterThan(0);
expect(
res.body.products[0].name === "Product 1" ||
res.body.products[0].name === "Product 2",
`The data received from GET /api/v1/products was not correct, update your code to return the correct data`,
options
).toBeTruthy();
expect(
res.req.method,
`When calling GET /api/v1/products, the method should be "GET", but it was "${res.req.method}", update the method to be "GET"`,
options
).toBe("GET");
expect(
res.type,
`When calling GET /api/v1/products, the content type should be "application/json", but it was "${res.type}", update the content type to be "application/json"`,
options
).toBe("application/json");
});
it("should check items in database", async () => {
await disconnectDB().then(async () => {
await connectDB(process.env.MONGODB_URI).then(async () => {
const products = await Product.find();
expect(
products.length,
`The database should contain all the "10 products" from the initial_data.json file, but it was "${products.length}", use the initial_data.json file to add more products to the api-experiment database`,
options
).toEqual(10);
await disconnectDB().then(async () => {
await connectDB(process.env.MONGODB_URI_TEST);
});
});
});
});
it("should return no products", async () => {
await Product.deleteMany();
const res = await request(app).get("/api/v1/products");
expect(
res.statusCode,
`The status code should be "404" because there are no products, but it was "${res.statusCode}", update the status code to be 404 when there are no products`,
options
).toBe(404);
expect(
res.body.message,
`The message should be "No products found" because there are no products, but it was "${res.body.message}", update the message to be "No products found" when there are no products`,
options
).toBe("No products found");
await createProducts();
});
it("should return error 500 if the database disconnected", async () => {
await disconnectDB().then(async () => {
const res = await request(app).get("/api/v1/products");
expect(
res.statusCode,
`The application should return 500 for the status code if the database is disconnected`,
options
).toBe(500);
await connectDB(process.env.MONGODB_URI_TEST);
});
});
});
describe("GET /api/v1/product/:slug", () => {
it("should return one product", async () => {
const res = await request(app).get("/api/v1/product/product-2");
expect(
res.statusCode,
`When calling GET /api/v1/product/:slug, the status code should be "200", but it was "${res.statusCode}", update your code to return 200`
).toBe(200);
expect(
res.body.product.name,
`The expected product was not correct, update your code to return the correct product`,
options
).toBe("Product 2");
expect(
res.req.method,
`When calling GET /api/v1/product/:slug, the method should be "GET", but it was "${res.req.method}", update the method to be "GET"`,
options
).toBe("GET");
expect(
res.type,
`When calling GET /api/v1/product/:slug, the content type should be "application/json", but it was "${res.type}", update the content type to be "application/json"`,
options
).toBe("application/json");
});
it("should return no product", async () => {
const res = await request(app).get("/api/v1/product/product-3");
expect(
res.statusCode,
`The status code should be "404" because there is no product, but it was "${res.statusCode}", update the status code to be 404 when there is no product found`,
options
).toBe(404);
expect(
res.body.message,
`The message should be "No product found" because there is no product, but it was "${res.body.message}", update the message to be "No product found" when there is no product found`,
options
).toBe("No product found");
});
it("should return error 500 if the database disconnected", async () => {
await disconnectDB().then(async () => {
const res = await request(app).get("/api/v1/product/product-2");
expect(
res.statusCode,
`The application should return 500 for the status code if the database is disconnected`,
options
).toBe(500);
await connectDB(process.env.MONGODB_URI_TEST);
});
});
});
describe("GET /api/v1/products with filters", () => {
it("should not return any products", async () => {
const formData = {
search: "John Doe",
};
const res = await request(app).get("/api/v1/products").query(formData);
expect(
res.statusCode,
`When applying search filters, if there are no products matching the search query, the status code should be "404". but it was "${res.statusCode}", update your code to return "404" when there are no products matching the search query`,
options
).toBe(404);
expect(
res.body.message,
`When applying search filters, if there are no products matching the search query, the message should be "No products found". but it was "${res.body.message}", update your code to return "No products found" when there are no products matching the search query`,
options
).toBe("No products found");
});
it("should return one products that fits the minimum price", async () => {
const formData = {
price: {
minPrice: 200,
},
};
const res = await request(app).get("/api/v1/products").query(formData);
expect(
res.statusCode,
`When applying price filters, if there are products matching the price query, the status code should be "200". but it was "${res.statusCode}", update your code to return "200" when there are products matching the price query`,
options
).toBe(200);
expect(
res.body.message,
`When applying price filters, if there are products matching the price query, the message should be "Products found". but it was "${res.body.message}", update your code to return "Products found" when there are products matching the price query`,
options
).toBe("Products found");
expect(
res.body.products.length,
`When applying price filters, if there are products matching the price query, the length of the products should the same with the products that match the price query, update your code to return the correct amount of products`,
options
).toBe(1);
expect(
res.body.products[0].price,
`When applying price filters, if there are products matching the price query, the price of the products should be greater than or equal to the minimum price, update your code to return the correct price of the products`,
options
).toBeGreaterThanOrEqual(200);
});
it("should return two products that fits the maximum price", async () => {
const formData = {
price: {
maxPrice: 1000,
},
};
const res = await request(app).get("/api/v1/products").query(formData);
expect(
res.statusCode,
`When applying price filters, if there are products matching the price query, the status code should be "200". but it was "${res.statusCode}", update your code to return "200" when there are products matching the price query`,
options
).toBe(200);
expect(
res.body.message,
`When applying price filters, if there are products matching the price query, the message should be "Products found". but it was "${res.body.message}", update your code to return "Products found" when there are products matching the price query`,
options
).toBe("Products found");
expect(
res.body.products.length,
`When applying price filters, if there are products matching the price query, the length of the products should the same with the products that match the price query, update your code to return the correct amount of products`,
options
).toBe(2);
expect(
res.body.products[0].price,
`When applying price filters, if there are products matching the price query, the price of the products should be less than or equal to the maximum price, update your code to return the correct price of the products`,
options
).toBeLessThanOrEqual(1000);
});
it("should return products", async () => {
const formData = {
search: "Product",
price: {
minPrice: 200,
maxPrice: 1000,
},
};
const res = await request(app).get("/api/v1/products").query(formData);
expect(
res.statusCode,
`When applying search and price filters, if there are products matching the search and price query, the status code should be "200". but it was "${res.statusCode}", update your code to return "200" when there are products matching the search and price query`,
options
).toBe(200);
expect(
res.body.message,
`When applying search and price filters, if there are products matching the search and price query, the message should be "Products found". but it was "${res.body.message}", update your code to return "Products found" when there are products matching the search and price query`,
options
).toBe("Products found");
expect(
res.body.products.length,
`When applying search and price filters, if there are products matching the search and price query, the length of the products should the same with the products that match the search and price query, update your code to return the correct amount of products`,
options
).toBe(1);
res.body.products.forEach((product) => {
expect(
product.price,
`When applying search and price filters, if there are products matching the search and price query, the price of the products should be greater than or equal to the minimum price and less than or equal to the maximum price, update your code to return the correct price of the products`,
options
).toBeGreaterThanOrEqual(200);
expect(
product.price,
`When applying search and price filters, if there are products matching the search and price query, the price of the products should be greater than or equal to the minimum price and less than or equal to the maximum price, update your code to return the correct price of the products`,
options
).toBeLessThanOrEqual(1000);
});
});
});
afterAll(async () => {
const collections = await mongoose.connection.db.collections();
for (let collection of collections) {
await collection.drop();
}
await disconnectDB();
});
async function createProducts() {
await Product.create(
{
name: "Product 1",
price: 100,
description: "Description 1",
},
{
name: "Product 2",
price: 200,
description: "Description 2",
}
);
}
async function connectDB(url) {
return mongoose.connect(url, {
useNewUrlParser: true,
useUnifiedTopology: true,
});
}
async function disconnectDB() {
await mongoose.connection.close();
}

View File

@ -0,0 +1,243 @@
const mongoose = require("mongoose");
const request = require("supertest");
const app = require("../../app");
const Product = require("../../models/product.model");
require("dotenv").config();
mongoose.set("strictQuery", true);
const options = {
showPrefix: false,
showMatcherMessage: true,
showStack: true,
};
beforeAll(async () => {
await connectDB(process.env.MONGODB_URI_TEST).then(
async () => {
console.log("Database connected successfully");
await createProducts();
},
(err) => {
console.log("There is problem while connecting database " + err);
}
);
});
describe("POST /api/v1/product", () => {
it("should create a product", async () => {
const res = await request(app).post("/api/v1/product").send({
name: "Product 3",
price: 1009,
description: "Description 3",
});
expect(
res.statusCode,
`Expected status code "201", but got "${res.statusCode}", the "201" is the status code for "Created" and it is the status code that we are expecting to get back from the server when we create a new product.`,
options
).toBe(201);
expect(
res.body,
`Expected the response body to have a property called "message" and the value of that property should be "Product created"`,
options
).toHaveProperty("message");
expect(
res.body.message,
`Expected the value of the "message" property to be "Product created"`,
options
).toBe("Product created");
expect(
res.body,
`Expected the response body to have a property called "product" and the value of that property should be an object`,
options
).toHaveProperty("product");
expect(
res.body.product.name,
`The value of the object returned doesn't match the value of the "name" property that we sent to the server.`,
options
).toBe("Product 3");
expect(
res.req.method,
`Expected the request method to be "POST"`,
options
).toBe("POST");
expect(
res.type,
`Expected the response type to be "application/json"`,
options
).toBe("application/json");
});
it("should not create a product because it already exists", async () => {
const res = await request(app).post("/api/v1/product").send({
name: "Product 3",
price: 1009,
description: "Description 3",
});
expect(
res.statusCode,
`Expected status code "409", but got "${res.statusCode}", the "409" is the status code for "Conflict" and it is the status code that we are expecting to get back from the server when we try to create a product that already exists.`,
options
).toBe(409);
expect(
res.body,
`Expected the response body to have a property called "message" and the value of that property should be "Product already exists"`,
options
).toHaveProperty("message");
expect(
res.body.message,
`Expected the value of the "message" property to be "Product already exists"`,
options
).toBe("Product already exists");
expect(
res.body,
`Expected the response body to have a property called "product" for the existed product and the value of that property should be an object`,
options
).toHaveProperty("product");
expect(
res.body.product.name,
`The value of the object returned doesn't match the value of the "name" property that we sent to the server.`,
options
).toBe("Product 3");
});
it("should not create a product because of the name is not provided", async () => {
const res = await request(app).post("/api/v1/product").send({
name: "",
price: 1009,
description: "Description 3",
});
expect(
res.statusCode,
`Expected status code "500", but got "${res.statusCode}", the "500" is the status code for "Internal Server Error" and it is the status code that we are expecting to get back from the server when we try to create a product without providing the name.`,
options
).toBe(500);
expect(
res.body.errors.name.message,
`Expected the value of the "message" property to be "Name is required", but got "${res.body.errors.name.message}" instead. Change the validation property of the "name" property in the "product.model.js" file to "required: true" and then run the test again.`,
options
).toBe("Name is required");
expect(
res.body.message,
`Expected the value of the "message" property to be "Product validation failed: name: Name is required", but got "${res.body.message}" instead. Change the validation property of the "name" property in the "product.model.js" file to "required: true" and then run the test again.`,
options
).toContain("Name is required");
});
it("should not create a product because of the price is not provided", async () => {
const res = await request(app).post("/api/v1/product").send({
name: "Product 4",
price: "",
description: "Description 4",
});
expect(
res.statusCode,
`Expected status code "500", but got "${res.statusCode}", the "500" is the status code for "Internal Server Error" and it is the status code that we are expecting to get back from the server when we try to create a product without providing the price.`,
options
).toBe(500);
expect(
res.body.message,
`Expected the value of the "message" property to be "Cast to Number failed" for value "" at path "price" for model "Product", but got "${res.body.message}" instead. Change the validation property of the "price" property in the "product.model.js" file to "required: true" and then run the test again.`,
options
).toContain("Cast to Number failed");
});
it("should not create a product because of the description is not provided", async () => {
const res = await request(app).post("/api/v1/product").send({
name: "Product 4",
price: 1009,
description: "",
});
expect(
res.statusCode,
`Expected status code "500", but got "${res.statusCode}", the "500" is the status code for "Internal Server Error" and it is the status code that we are expecting to get back from the server when we try to create a product without providing the description.`,
options
).toBe(500);
expect(
res.body.errors.description.message,
`Expected the value of the "message" property to be "Description is required", but got "${res.body.errors.description.message}" instead. Change the validation property of the "description" property in the "product.model.js" file to "required: true" and then run the test again.`,
options
).toBe("Description is required");
expect(
res.body.message,
`Expected the value of the "message" property to be "Product validation failed: description: Description is required", but got "${res.body.message}" instead. Change the validation property of the "description" property in the "product.model.js" file to "required: true" and then run the test again.`,
options
).toContain("Description is required");
});
it("should not create a product because of the price is less than 0", async () => {
const res = await request(app).post("/api/v1/product").send({
name: "Product 4",
price: -1009,
description: "Description 4",
});
expect(
res.statusCode,
`Expected status code "500", but got "${res.statusCode}", the "500" is the status code for "Internal Server Error" and it is the status code that we are expecting to get back from the server when we try to create a product with a price less than 0.
`,
options
).toBe(500);
expect(
res.body.errors.price.message,
`Expected the value of the "message" property to be "Price must be greater than 0", but got "${res.body.errors.price.message}" instead. Change the validation property of the "price" property in the "product.model.js" file to "min: 0" and then run the test again.`,
options
).toBe("Price must be greater than 0");
expect(
res.body.message,
`Expected the value of the "message" property to be "Price must be greater than 0", but got "${res.body.message}" instead. Change the validation property of the "price" property in the "product.model.js" file to "min: 0" and then run the test again.`,
options
).toContain("Price must be greater than 0");
});
it("should return error 500", async () => {
await disconnectDB().then(async () => {
const res = await request(app).post("/api/v1/product").send({
name: "Product 4",
price: 1009,
description: "Description 4",
});
expect(
res.statusCode,
`Expected status code "500", but got "${res.statusCode}", the "500" is the status code for "Internal Server Error" and it is the status code that we are expecting to get back from the server when we try to create a product without database connection.`,
options
).toBe(500);
await connectDB();
});
});
});
afterAll(async () => {
const collections = await mongoose.connection.db.collections();
for (let collection of collections) {
await collection.drop();
}
await disconnectDB();
});
async function createProducts() {
await Product.create(
{
name: "Product 1",
price: 100,
description: "Description 1",
},
{
name: "Product 2",
price: 200,
description: "Description 2",
}
);
}
async function connectDB() {
return mongoose.connect(process.env.MONGODB_URI_TEST, {
useNewUrlParser: true,
useUnifiedTopology: true,
});
}
async function disconnectDB() {
await mongoose.connection.close();
}

View File

@ -0,0 +1,153 @@
const mongoose = require("mongoose");
const request = require("supertest");
const app = require("../../app");
const Product = require("../../models/product.model");
require("dotenv").config();
mongoose.set("strictQuery", true);
const options = {
showPrefix: false,
showMatcherMessage: true,
showStack: true,
};
beforeAll(async () => {
await connectDB(process.env.MONGODB_URI_TEST).then(
async () => {
console.log("Database connected successfully");
await createProducts();
},
(err) => {
console.log("There is problem while connecting database " + err);
}
);
});
describe("PATCH /api/v1/product/:slug", () => {
it("should update a product", async () => {
const FindProduct = await Product.findOne({ slug: "product-3" })
.lean()
.exec();
const res = await request(app).patch("/api/v1/product/product-3").send({
name: "Product 3 updated",
price: 109,
description: "Description 3 updated",
});
expect(
res.statusCode,
`Expected status code 200, but got "${res.statusCode}", the status 200 means that the request has succeeded. Change it in the file "controllers/api/product.controller.js"`
).toBe(200);
expect(
res.body,
`Expected the response body to have a property called "message" and the value of that property should be "Product updated"`,
options
).toHaveProperty("message");
expect(
res.body.message,
`Expected the value of the "message" property to be "Product updated"`,
options
).toBe("Product updated");
expect(
res.body,
`Expected the response body to have a property called "product" and the value of that property should be an object`,
options
).toHaveProperty("product");
expect(
res.body.product,
`Expected the value of the product sent to the server to be updated but it is not. Make sure that you are using the "findByIdAndUpdate" method and that you are passing the correct parameters to it.`
).not.toEqual(FindProduct);
expect(
res.req.method,
`Expected the request method to be "PATCH"`,
options
).toBe("PATCH");
expect(
res.type,
`Expected the response content type to be "application/json"`,
options
).toBe("application/json");
});
it("should not update a product because it does not exist", async () => {
const res = await request(app).patch("/api/v1/product/product_4").send({
name: "Product 3 updated",
price: 109,
description: "Description 3",
});
expect(
res.statusCode,
`Expected status code 404, but got "${res.statusCode}", the status 404 means that the server can not find the requested resource. Change it in the file "controllers/api/product.controller.js"`,
options
).toBe(404);
expect(
res.body,
`Expected the response body to have a property called "message" and the value of that property should be "No product found"`,
options
).toHaveProperty("message");
expect(
res.body.message,
`Expected the value of the "message" property to be "No product found"`,
options
).toBe("No product found");
});
it("should return error 500", async () => {
await disconnectDB().then(async () => {
const res = await request(app).patch("/api/v1/product/product_4").send({
name: "Product 3 updated",
price: 109,
description: "Description 3",
});
expect(
res.statusCode,
`Expected status code "500", but got "${res.statusCode}", the "500" is the status code for "Internal Server Error" and it is the status code that we are expecting to get back from the server when we try to update a product without database connection.`,
options
).toBe(500);
await connectDB();
});
});
});
afterAll(async () => {
const collections = await mongoose.connection.db.collections();
for (let collection of collections) {
await collection.drop();
}
await disconnectDB();
});
async function createProducts() {
await Product.create(
{
name: "Product 1",
price: 100,
description: "Description 1",
},
{
name: "Product 2",
price: 200,
description: "Description 2",
},
{
name: "Product 3",
price: 1009,
description: "Description 3",
}
);
}
async function connectDB() {
return mongoose.connect(process.env.MONGODB_URI_TEST, {
useNewUrlParser: true,
useUnifiedTopology: true,
});
}
async function disconnectDB() {
await mongoose.connection.close();
}

View File

@ -0,0 +1,141 @@
const mongoose = require("mongoose");
const request = require("supertest");
const app = require("../../app");
const Product = require("../../models/product.model");
require("dotenv").config();
mongoose.set("strictQuery", true);
const options = {
showPrefix: false,
showMatcherMessage: true,
showStack: true,
};
beforeAll(async () => {
await connectDB(process.env.MONGODB_URI_TEST).then(
async () => {
console.log("Database connected successfully");
await createProducts();
},
(err) => {
console.log("There is problem while connecting database " + err);
}
);
});
describe("DELETE /api/v1/products/:slug", () => {
it("should delete a product", async () => {
const product = await Product.findOne({ slug: "product-3" }).lean().exec();
const res = await request(app).delete("/api/v1/product/product-3");
expect(
res.statusCode,
`Expected status code 200 when requesting to delete a product, but got "${res.statusCode}", the status 200 means that the request has succeeded. Change it in the file "controllers/api/product.controller.js"`
).toBe(200);
expect(
res.body,
`Expected the response body to have a property called "message" and the value of that property should be "Product deleted"`,
options
).toHaveProperty("message");
expect(
res.body.message,
`Expected the value of the "message" property to be "Product deleted"`,
options
).toBe("Product deleted");
expect(
res.body,
`Expected the response body to have a property called "product" for the deleted product and the value of that property should be an object`,
options
).toHaveProperty("product");
expect(
res.body.product.name,
`Expected the product deleted to be the same as the product sent to the server but it is not. Make sure that you are using the "findByIdAndDelete" method and that you are passing the correct parameters to it.`,
options
).toBe("Product 3");
const checkProduct = await Product.findById(product._id).lean().exec();
expect(
checkProduct,
`Expected the product to be deleted from the database but it is not. Make sure that you are using the "findByIdAndDelete" method and that you are passing the correct parameters to it.`,
options
).toBeNull();
expect(
res.req.method,
`Expected the request method to be "DELETE" but it is not`,
options
).toBe("DELETE");
expect(
res.type,
`Expected the response type to be "application/json" but it is not`,
options
).toBe("application/json");
});
it("should not delete a product because it does not exist", async () => {
const res = await request(app).delete("/api/v1/product/product-3");
expect(
res.statusCode,
`Expected status code "404" when requesting to delete a product that does not exist, but got "${res.statusCode}", the status 404 means that the server can not find the requested resource. Change it in the file "controllers/api/product.controller.js"`
).toBe(404);
expect(
res.body,
`Expected the response body to have a property called "message" and the value of that property should be "No product found"`,
options
).toHaveProperty("message");
expect(
res.body.message,
`Expected the value of the "message" property to be "No product found"`,
options
).toBe("No product found");
});
it("should return error 500", async () => {
await disconnectDB().then(async () => {
const res = await request(app).delete("/api/v1/product/product_4");
expect(
res.statusCode,
`Expected status code "500", but got "${res.statusCode}", the "500" is the status code for "Internal Server Error" and it is the status code that we are expecting to get back from the server when we try to delete a product without database connection.`,
options
).toBe(500);
await connectDB();
});
});
});
afterAll(async () => {
const collections = await mongoose.connection.db.collections();
for (let collection of collections) {
await collection.drop();
}
await disconnectDB();
});
async function createProducts() {
await Product.create(
{
name: "Product 1",
price: 100,
description: "Description 1",
},
{
name: "Product 2",
price: 200,
description: "Description 2",
},
{
name: "Product 3",
price: 1009,
description: "Description 3",
}
);
}
async function connectDB() {
return mongoose.connect(process.env.MONGODB_URI_TEST, {
useNewUrlParser: true,
useUnifiedTopology: true,
});
}
async function disconnectDB() {
await mongoose.connection.close();
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

View File

@ -0,0 +1,209 @@
const fs = require("fs");
const puppeteer = require("puppeteer");
const { toMatchImageSnapshot } = require("jest-image-snapshot");
expect.extend({ toMatchImageSnapshot });
require("dotenv").config();
const options = {
showPrefix: false,
showMatcherMessage: true,
showStack: true,
};
let browser;
let page;
beforeAll(async () => {
browser = await puppeteer.launch({
headless: true,
slowMo: 0,
devtools: false,
defaultViewport: {
width: 1024,
height: 768,
},
});
page = await browser.newPage();
await page.setDefaultTimeout(10000);
await page.setDefaultNavigationTimeout(20000);
});
beforeEach(async () => {
await page.goto(`http://localhost:${process.env.PORT}/`);
});
afterAll(async () => {
await browser.close();
});
describe("Testing the index page title and content", () => {
it("should have the right title", async () => {
const title = await page.title();
expect(
title,
`The title for the web page "${title}" is wrong it should be "API-Experiment | Home" Make sure that the function handling the GET "/" route is sending the right title`,
options
).toBe("API-Experiment | Home");
});
it("should have a button with the text 'Products' and url `/products` ", async () => {
const button = await page.$eval(
".btn.btn-primary",
(el) => el.textContent
);
expect(
button,
`The button with the text "Products" is not present on the page`,
options
).toBe("Products");
const url = await page.$eval(".btn.btn-primary", (el) => el.href);
expect(
url,
`The button with the text "Products" is not sending the user to the right url`,
options
).toBe(`http://localhost:${process.env.PORT}/products`);
const backgroundColor = await page.evaluate(() => {
const button = document.querySelector(".btn.btn-primary");
const style = window.getComputedStyle(button);
return style.getPropertyValue("background-color");
});
expect(
backgroundColor,
`The button has the wrong background color "${backgroundColor}" it should be "rgb(0, 161, 189)"`
).toBe("rgb(0, 161, 189)");
});
it("should have nav bar with 2 links", async () => {
const navBar = await page.$eval("nav", (el) => el.textContent);
expect(
navBar,
`The page should contain a link to the home page. Check the "main.ejs" file in the "web/views/layouts" folder to find the nav bar"`,
options
).toContain("Home");
expect(
navBar,
`The page should contain a link to the products page. Check the "main.ejs" file in the "web/views/layouts" folder to find the nav bar`,
options
).toContain("Products");
});
});
describe("Testing the index page for receiving messages", () => {
it("should receive a message and display it", async () => {
await page.goto(
`http://localhost:${process.env.PORT}/?message=Hello test`
);
let message = await page.$eval(".message", (el) => el.textContent);
expect(
message,
`the message "${message}" received is wrong it should be "Hello test"`,
options
).toBe("Hello test");
await page.goto(
`http://localhost:${process.env.PORT}/?message=This is another test`
);
message = await page.$eval(".message", (el) => el.textContent);
expect(
message,
`the message "${message}" received is wrong it should be "This is another test"`,
options
).toBe("This is another test");
});
it("should have the correct color for the box after receiving a message", async () => {
await page.goto(
`http://localhost:${process.env.PORT}/?message=yet, another test`
);
const backgroundColor = await page.evaluate(() => {
const message = document.querySelector(".alert.alert-success");
const style = window.getComputedStyle(message);
return style.getPropertyValue("background-color");
});
expect(
backgroundColor,
`The message box has the wrong background color "${backgroundColor}" it should be "rgb(239, 162, 95)"`
).toBe("rgb(239, 162, 95)");
});
});
describe("Testing the error `Not Found` page", () => {
it("should have the right title", async () => {
await page.goto(
`http://localhost:${process.env.PORT}/thisurldoesnotexist`
);
const title = await page.title();
expect(
title,
`The title for the web page "${title}" is wrong it should be "API-Experiment | Error" Make sure that the function handling the GET "/:url" route is sending the right title`,
options
).toBe("API-Experiment | Error");
});
it("should have a status code of 404", async () => {
await page.goto(
`http://localhost:${process.env.PORT}/thisurldoesnotexist`
);
const statusCode = await page.$eval(".title", (el) => el.textContent);
expect(
statusCode,
`The status code "${statusCode}" is wrong it should be "404" Make sure that the function handling the GET "/:url" route is sending the right status code`,
options
).toBe("404");
});
it("should have a message saying `NOT FOUND`", async () => {
await page.goto(
`http://localhost:${process.env.PORT}/thisurldoesnotexist`
);
const message = await page.$eval(".message", (el) => el.textContent);
expect(
message,
`The message "${message}" is wrong it should be "NOT FOUND" Make sure that the function handling the GET "/:url" route is sending the right message`,
options
).toBe("NOT FOUND");
});
});
describe("Testing the index page and error `Not Found` page image snapshots", () => {
it("matches the expected styling", async () => {
if (!fs.existsSync("tests/web/images/index-page.png")) {
throw new Error(
`The reference image for the index page does not exist, please import the image from the "tests/web/images/index-page.png"`
);
}
const screenshot = await page.screenshot({ fullPage: true });
expect(
screenshot,
`The web styling for the index page is not correct check the file "tests/web/images/__diff_output__/index-page-diff.png" to find the difference`,
options
).toMatchImageSnapshot({
customDiffConfig: { threshold: 0.9 },
customSnapshotsDir: "tests/web/images",
customSnapshotIdentifier: "index-page",
});
});
it("matches the expected styling", async () => {
if (!fs.existsSync("tests/web/images/error-notFound-page.png")) {
throw new Error(
`The reference image for the error page does not exist, please import the image from the "tests/web/images/error-notFound-page.png"`
);
}
await page.goto(
`http://localhost:${process.env.PORT}/thisurldoesnotexist`
);
const screenshot = await page.screenshot({ fullPage: true });
expect(
screenshot,
`The web styling for the error "Not Found" page is not correct check the file "tests/web/images/__diff_output__/error-notFound-page-diff.png" to find the difference`,
options
).toMatchImageSnapshot({
customDiffConfig: { threshold: 0.9 },
customSnapshotsDir: "tests/web/images",
customSnapshotIdentifier: "error-notFound-page",
});
});
});

View File

@ -0,0 +1,399 @@
const fs = require("fs");
const puppeteer = require("puppeteer");
const { toMatchImageSnapshot } = require("jest-image-snapshot");
const initial_data = JSON.parse(fs.readFileSync("./initial_data.json"));
expect.extend({ toMatchImageSnapshot });
require("dotenv").config();
const options = {
showPrefix: false,
showMatcherMessage: true,
showStack: true,
};
let browser;
let page;
beforeAll(async () => {
browser = await puppeteer.launch({
headless: true,
slowMo: 0,
devtools: false,
defaultViewport: {
width: 1024,
height: 768,
},
});
page = await browser.newPage();
await page.setDefaultTimeout(10000);
await page.setDefaultNavigationTimeout(20000);
});
beforeEach(async () => {
await page.goto(`http://localhost:${process.env.PORT}/products`);
});
afterAll(async () => {
await browser.close();
});
describe("Testing the products page title and content", () => {
it("should have the right title", async () => {
const title = await page.title();
expect(
title,
`The title for the web page "${title}" is wrong it should be "API-Experiment | Products" Make sure that the function handling the GET "/products" route is sending the right title`,
options
).toBe("API-Experiment | Products");
});
it("should have a header with the text 'Products' with 'title' class", async () => {
const header = await page.$eval(".title", (el) => el.textContent);
expect(
header,
`The header with the text "Products" is not present on the page`,
options
).toBe("Products");
});
it("should have the search form with the three inputs and the submit button", async () => {
const inputs = await page.$$("input");
expect(
inputs.length,
`The number of inputs in the search form is wrong, it should be 3`,
options
).toBe(3);
const button = await page.$eval(
"form > button.btn.btn-primary",
(el) => el.textContent
);
expect(
button,
`The submit button is not present on the page`,
options
).toBeTruthy();
expect(
button,
`The submit button should have the text "Search", but it has "${button}"`,
options
).toBe("Search");
});
it("should have the correct form action and method", async () => {
const form = await page.$eval("form", (el) => ({
action: el.action,
method: el.method,
}));
expect(
form.action,
`The form action should be "http://localhost:${process.env.PORT}/products", but it has "${form.action}", You can change it in the "web/views/products/index.ejs" file.`,
options
).toBe(`http://localhost:${process.env.PORT}/products`);
expect(
form.method,
`The form method should be "GET", but it has "${form.method}", You can change it in the "web/views/products/index.ejs" file.`,
options
).toBe("get");
});
it("should the right inputs names and types", async () => {
const search_input = await page.$eval("#search", (el) => ({
name: el.name,
type: el.type,
}));
expect(
search_input.name,
`The first input should have the name "search", but it has "${search_input.name}"`,
options
).toBe("search");
expect(
search_input.type,
`The first input should have the type "text", but it has "${search_input.type}"`,
options
).toBe("text");
const minPrice_input = await page.$eval("#minPrice", (el) => ({
name: el.name,
type: el.type,
}));
expect(
minPrice_input.name,
`The second input should have the name "price[minPrice]", but it has "${minPrice_input.name}"`,
options
).toBe("price[minPrice]");
expect(
minPrice_input.type,
`The second input should have the type "number", but it has "${minPrice_input.type}"`,
options
).toBe("number");
const maxPrice_input = await page.$eval("#maxPrice", (el) => ({
name: el.name,
type: el.type,
}));
expect(
maxPrice_input.name,
`The third input should have the name "price[maxPrice]", but it has "${maxPrice_input.name}"`,
options
).toBe("price[maxPrice]");
expect(
maxPrice_input.type,
`The third input should have the type "number", but it has "${maxPrice_input.type}"`,
options
).toBe("number");
});
it("should have a button to create a new product", async () => {
const button = await page.$eval(".btn.btn-primary", (el) => ({
text: el.textContent,
url: el.href,
}));
expect(
button.text,
`The button to create a new product should have the text "Create a new product", but it has "${button.text}"`,
options
).toBe("Create a new product");
expect(
button.url,
`The button to create a new product should have the url "http://localhost:${process.env.PORT}/products/create", but it has "${button.url}"`,
options
).toBe(`http://localhost:${process.env.PORT}/products/create`);
});
});
describe("Testing the products page table", () => {
it("should have the right number of products", async () => {
const products = await page.$$("tbody tr");
expect(
products.length,
`The number of products is wrong, it should be 10`,
options
).toBe(10);
});
it("should have the right data", async () => {
const products_data = await page.$$eval("tbody tr", (rows) =>
rows.map((row) => {
const [no, name, price, description, slug] = row.children;
return {
name: name.textContent,
price: parseFloat(price.textContent.replace("$", "")),
description: description.textContent,
slug: slug.children[0].href.split("/show/").pop(),
};
})
);
initial_data.forEach((product) => {
delete product._id;
delete product.createdAt;
delete product.updatedAt;
});
products_data.sort((a, b) => a.name.localeCompare(b.name));
initial_data.sort((a, b) => a.name.localeCompare(b.name));
expect(products_data, `The products data is wrong`, options).toEqual(
initial_data
);
});
});
describe("Testing the products details page", () => {
it("should go to the details page when clicking on a product", async () => {
await page.click("tbody tr:first-child a");
const url = await page.url();
const slug = url.split("/show/").pop();
const product = initial_data.find((product) => product.slug === slug);
expect(
product,
`The product with the slug "${slug}" is not present in the initial_data.json file`,
options
).toBeTruthy();
expect(
url,
`The url for the details page is wrong, it should be "http://localhost:${process.env.PORT}/products/show/${product.slug}", but it is "${url}"`,
options
).toBe(
`http://localhost:${process.env.PORT}/products/show/${product.slug}`
);
});
it("should have the button to edit and delete the product", async () => {
await page.click("tbody tr:first-child a");
const url = await page.url();
const slug = url.split("/show/").pop();
const product = initial_data.find((product) => product.slug === slug);
const productName = await page.$eval(
".card-title",
(el) => el.textContent
);
const productPrice = await page.$eval(
".card-subtitle",
(el) => el.textContent
);
const productDescription = await page.$eval(
".card-text",
(el) => el.textContent
);
const editButton = await page.$eval("a.btn", (el) => ({
text: el.textContent.trim(),
url: el.href.trim(),
}));
const deleteButton = await page.$eval("form > button.btn", (el) => ({
text: el.textContent.trim(),
url: el.parentElement.action.trim(),
}));
expect(
productName,
`The product name is wrong, it should be "${product.name}", but it is "${productName}"`,
options
).toBe(product.name);
expect(
productPrice,
`The product price is wrong, it should be "${product.price}", but it is "$${productPrice}"`,
options
).toBe("$" + product.price);
expect(
productDescription,
`The product description is wrong, it should be "${product.description}", but it is "${productDescription}"`,
options
).toBe(product.description);
expect(
editButton.text,
`The edit button should have the text "Edit this product", but it has "${editButton.text}", change it in the "views/products/details.ejs" file`,
options
).toBe("Edit this product");
expect(
editButton.url,
`The edit button should have the url "http://localhost:${process.env.PORT}/products/update/${product.slug}", but it has "${editButton.url}", make sure you are using the correct slug by using "/products/update/<%= product.slug %>" url, change it in the "views/products/details.ejs" file`,
options
).toBe(
`http://localhost:${process.env.PORT}/products/update/${product.slug}`
);
expect(
deleteButton.text,
`The delete button should have the text "Delete this product", but it has "${deleteButton.text}", change it in the "views/products/details.ejs" file`,
options
).toBe("Delete this product");
expect(
deleteButton.url,
`The delete button should have the url "http://localhost:${process.env.PORT}/products/delete/${product.slug}", but it has "${deleteButton.url}", make sure you are using the correct slug by using "/products/delete/<%= product.slug %>" url, change it in the "views/products/details.ejs" file`,
options
).toBe(
`http://localhost:${process.env.PORT}/products/delete/${product.slug}`
);
});
it("should don't go to the product's details page if the product does not exist", async () => {
await page.goto(
`http://localhost:${process.env.PORT}/products/show/123thisproductdoenotexist`
);
const title = await page.title();
expect(
title,
`The title for the web page "${title}" is wrong it should be "API-Experiment | Error" Make sure that the function handling the GET getProduct method return the error title if the product was not found`,
options
).toBe("API-Experiment | Error");
const statusCode = await page.$eval(".title", (el) => el.textContent);
expect(
statusCode,
`The status code "${statusCode}" is wrong it should be "404" Make sure that the function handling the GET getProduct method return the error status code if the product was not found`,
options
).toBe("404");
const message = await page.$eval(".message", (el) => el.textContent);
expect(
message,
`The message "${message}" is wrong it should be "No product found" Make sure that the function handling the GET getProduct method return the error message if the product was not found`,
options
).toBe("No product found");
});
});
describe("Testing the product pages image snapshots", () => {
it("should have the right image for the products page", async () => {
if (!fs.existsSync("tests/web/images/products-table-page.png")) {
throw new Error(
`The reference image for the products table page does not exist, please import the image from the "tests/web/images/products-table-page.png"`
);
}
const image = await page.screenshot({ fullPage: true });
expect(
image,
`The image for the products table page is wrong, it should be the same as the "tests/web/images/__diff_output__/products-table-page-diff.png" image`,
options
).toMatchImageSnapshot({
customDiffConfig: { threshold: 0.9 },
customSnapshotsDir: "tests/web/images",
customSnapshotIdentifier: "products-table-page",
});
});
it("should have the right image for the details page", async () => {
if (!fs.existsSync("tests/web/images/product-details-page.png")) {
throw new Error(
`The reference image for the product details page does not exist, please import the image from the "tests/web/images/product-details-page.png"`
);
}
await page.goto(
`http://localhost:${process.env.PORT}/products/show/${initial_data[0].slug}`
);
const image = await page.screenshot({ fullPage: true });
expect(
image,
`The image for the product details page is wrong, it should be the same as the "tests/web/images/__diff_output__/product-details-page-diff.png" image`,
options
).toMatchImageSnapshot({
customDiffConfig: { threshold: 0.9 },
customSnapshotsDir: "tests/web/images",
customSnapshotIdentifier: "product-details-page",
});
});
it("should match the not found product snapshot", async () => {
if (!fs.existsSync("tests/web/images/not-found-product-page.png")) {
throw new Error(
`The reference image for the not found product page does not exist, please import the image from the "tests/web/images/not-found-product-page.png"`
);
}
await page.goto(
`http://localhost:${process.env.PORT}/products/show/123`
);
const image = await page.screenshot({ fullPage: true });
expect(
image,
`The image for the not found product page is wrong, it should be the same as the "tests/web/images/__diff_output__/not-found-product-page-diff.png" image`
).toMatchImageSnapshot({
customDiffConfig: { threshold: 0.9 },
customSnapshotsDir: "tests/web/images",
customSnapshotIdentifier: "not-found-product-page",
});
});
it("should match no products found snapshot", async () => {
if (!fs.existsSync("tests/web/images/no-products-found-page.png")) {
throw new Error(
`The reference image for the no products found page does not exist, please import the image from the "tests/web/images/no-products-found-page.png"`
);
}
await page.goto(
`http://localhost:${process.env.PORT}/products?search=123ThisIsNotAProduct`
);
const image = await page.screenshot({ fullPage: true });
expect(
image,
`The image for the no products found page is wrong, it should be the same as the "tests/web/images/__diff_output__/no-products-found-page-diff.png" image`
).toMatchImageSnapshot({
customDiffConfig: { threshold: 0.9 },
customSnapshotsDir: "tests/web/images",
customSnapshotIdentifier: "no-products-found-page",
});
});
});

View File

@ -0,0 +1,246 @@
const fs = require("fs");
const puppeteer = require("puppeteer");
const { toMatchImageSnapshot } = require("jest-image-snapshot");
const initial_data = JSON.parse(fs.readFileSync("./initial_data.json"));
const mongoose = require("mongoose");
expect.extend({ toMatchImageSnapshot });
require("dotenv").config();
const options = {
showPrefix: false,
showMatcherMessage: true,
showStack: true,
};
let browser;
let page;
beforeAll(async () => {
browser = await puppeteer.launch({
headless: true,
slowMo: 0,
devtools: false,
defaultViewport: {
width: 1024,
height: 768,
},
});
page = await browser.newPage();
await page.setDefaultTimeout(10000);
await page.setDefaultNavigationTimeout(20000);
});
beforeEach(async () => {
await page.goto(`http://localhost:${process.env.PORT}/products/create`);
mongoose.set("strictQuery", true);
await mongoose.connect(process.env.MONGODB_URI, {
useNewUrlParser: true,
useUnifiedTopology: true,
});
await mongoose.connection.collection("products").deleteMany({});
initial_data.forEach((product) => {
delete product._id;
delete product.createdAt;
delete product.updatedAt;
});
await mongoose.connection.collection("products").insertMany(initial_data);
await mongoose.connection.close();
});
afterAll(async () => {
await browser.close();
});
describe("Testing the create product page title and content", () => {
it("should have the correct title", async () => {
const title = await page.title();
expect(
title,
`The title received "${title}" of the page is not correct, it should be "API-Experiment | Create Product". Change the title of the page to match the expected one. You can change it in the "controllers/web/product.controller.js" file.`,
options
).toBe("API-Experiment | Create Product");
});
it("should have the correct content title and description", async () => {
const title = await page.$eval(".title", (el) => el.textContent);
const description = await page.$eval(
".description",
(el) => el.textContent
);
expect(
title,
`The title received "${title}" of the page's header is not correct, it should be "Create a new product". Change the title of the page to match the expected one. You can change it in the "web/views/products/create.ejs" file.`,
options
).toBe("Create a new product");
expect(
description,
`The description received "${description}" of the page's header is not correct, it should be "Fill the form below to create a new product". Change the description of the page to match the expected one. You can change it in the "web/views/products/create.ejs" file.`,
options
).toBe("Fill the form below to create a new product");
});
});
describe("Testing the create product page form", () => {
it("should have the correct form fields", async () => {
const inputs = await page.$$("input");
expect(
inputs.length,
`The number of inputs in the create form is wrong, it should be 2`,
options
).toBe(2);
const textarea = await page.$$("textarea");
expect(
textarea.length,
`The number of textarea in the create form is wrong, it should be 1`,
options
).toBe(1);
const button = await page.$eval(
"form > button.btn.btn-primary",
(el) => el.textContent
);
expect(
button,
`The submit button is not present on the page, You can change it in the "web/views/products/create.ejs" file.`,
options
).toBeTruthy();
expect(
button,
`The submit button should have the text "Create", but it has "${button}", You can change it in the "web/views/products/create.ejs" file.`,
options
).toBe("Create");
});
it("should the right inputs names and types", async () => {
const name_input = await page.$eval("#name", (el) => ({
name: el.name,
type: el.type,
}));
expect(
name_input.name,
`The name input should have the name "name", but it has "${name_input.name}", You can change it in the "web/views/products/create.ejs" file.`,
options
).toBe("name");
expect(
name_input.type,
`The name input should have the type "text", but it has "${name_input.type}", You can change it in the "web/views/products/create.ejs" file.`,
options
).toBe("text");
const price_input = await page.$eval("#price", (el) => ({
name: el.name,
type: el.type,
}));
expect(
price_input.name,
`The price input should have the name "price", but it has "${price_input.name}", You can change it in the "web/views/products/create.ejs" file.`,
options
).toBe("price");
expect(
price_input.type,
`The price input should have the type "number", but it has "${price_input.type}", You can change it in the "web/views/products/create.ejs" file.`,
options
).toBe("number");
const description_input = await page.$eval("#description", (el) => ({
name: el.name,
rows: el.rows,
}));
expect(
description_input.name,
`The description textarea should have the name "description", but it has "${description_input.name}", You can change it in the "web/views/products/create.ejs" file.`,
options
).toBe("description");
expect(
description_input.rows,
`The description textarea should have "5" rows "number", but it has "${description_input.rows}", You can change it in the "web/views/products/create.ejs" file.`,
options
).toBe(5);
});
it("should have the correct form action and method", async () => {
const form = await page.$eval("form", (el) => ({
action: el.action,
method: el.method,
}));
expect(
form.action,
`The form action should be "http://localhost:${process.env.PORT}/products/create", but it has "${form.action}", You can change it in the "web/views/products/create.ejs" file.`,
options
).toBe(`http://localhost:${process.env.PORT}/products/create`);
expect(
form.method,
`The form method should be "POST", but it has "${form.method}", You can change it in the "web/views/products/create.ejs" file.`,
options
).toBe("post");
});
});
describe("Testing the create product page form submission", () => {
it("should create a new product", async () => {
await page.type("#name", "Test Product");
await page.type("#price", "100");
await page.type("#description", "Test Product Description");
await page.click("form > button.btn.btn-primary");
await new Promise((resolve) => setTimeout(resolve, 1000));
const message = await page.$eval("p.message", (el) => el.textContent);
expect(
message,
`The message received "${message}" of the page is not correct, it should be "Product created". Change the message of the page to match the expected one. You can change it in the "controllers/web/product.controller.js" file.`,
options
).toBe("Product created");
const newProduct = await page.$eval(
"table > tbody > tr:last-child",
(el) => ({
name: el.children[1].textContent,
price: el.children[2].textContent,
description: el.children[3].textContent,
})
);
expect(
newProduct,
`The test product created seems to be not in the table of products, make sure that the product after being created is added to the table of products. You can change it in the "controllers/web/product.controller.js" file.`
).toEqual({
name: "Test Product",
price: "$100",
description: "Test Product Description",
});
});
it("should not create a new product with empty fields", async () => {
await page.type("#name", "New Product");
await page.click("form > button.btn.btn-primary");
await new Promise((resolve) => setTimeout(resolve, 1000));
const message = await page.$eval("p.message", (el) => el.textContent);
expect(
message,
`The message received "${message}" of the page is not correct, it should be "Please fill all fields". Change the message of the page to match the expected one. You can change it in the "controllers/web/product.controller.js" file.`,
options
).toBe("Please fill all fields");
});
});
describe("Testing the create page image snapshot", () => {
it("should match the reference image", async () => {
if (!fs.existsSync("tests/web/images/create-product-page.png")) {
throw new Error(
`The reference image for the create product page does not exist, please import the image from the "tests/web/images/create-product-page.png"`
);
}
const image = await page.screenshot({ fullPage: true });
expect(
image,
`The image for the create product page is wrong, it should be the same as the "tests/web/images/__diff_output__/create-product-page-diff.png" image`
).toMatchImageSnapshot({
customDiffConfig: { threshold: 0.9 },
customSnapshotsDir: "tests/web/images",
customSnapshotIdentifier: "create-product-page",
});
});
});

View File

@ -0,0 +1,342 @@
const fs = require("fs");
const puppeteer = require("puppeteer");
const { toMatchImageSnapshot } = require("jest-image-snapshot");
const initial_data = JSON.parse(fs.readFileSync("./initial_data.json"));
const mongoose = require("mongoose");
expect.extend({ toMatchImageSnapshot });
require("dotenv").config();
const options = {
showPrefix: false,
showMatcherMessage: true,
showStack: true,
};
let browser;
let page;
let product;
beforeAll(async () => {
browser = await puppeteer.launch({
headless: true,
slowMo: 0,
devtools: false,
defaultViewport: {
width: 1024,
height: 768,
},
});
page = await browser.newPage();
await page.setDefaultTimeout(10000);
await page.setDefaultNavigationTimeout(20000);
});
beforeEach(async () => {
mongoose.set("strictQuery", true);
await mongoose.connect(process.env.MONGODB_URI, {
useNewUrlParser: true,
useUnifiedTopology: true,
});
await mongoose.connection.collection("products").deleteMany({});
initial_data.forEach((product) => {
delete product._id;
delete product.createdAt;
delete product.updatedAt;
});
await mongoose.connection.collection("products").insertMany(initial_data);
await mongoose.connection.close();
await page.goto(`http://localhost:${process.env.PORT}/products`);
await page.click("tbody tr:first-child a");
const url = await page.url();
const slug = url.split("/show/").pop();
product = initial_data.find((product) => product.slug === slug);
await page.goto(
`http://localhost:${process.env.PORT}/products/update/${slug}`
);
});
afterAll(async () => {
await browser.close();
});
describe("Testing the update page title and content", () => {
it("should have the correct title", async () => {
const title = await page.title();
expect(
title,
`The title received "${title}" of the page is not correct, it should be "API-Experiment | Update Product". Change the title of the page to match the expected one. You can change it in the "controllers/web/product.controller.js" file.`,
options
).toBe("API-Experiment | Update Product");
});
it("should have the correct content title and description", async () => {
const title = await page.$eval(".title", (el) => el.textContent);
const description = await page.$eval(
".description",
(el) => el.textContent
);
expect(
title,
`The title received "${title}" of the page's header is not correct, it should be "Update this product". Change the title of the page to match the expected one. You can change it in the "web/views/products/update.ejs" file.`,
options
).toBe("Update this product");
expect(
description,
`The description received "${description}" of the page's header is not correct, it should be "Fill the form below to update this product". Change the description of the page to match the expected one. You can change it in the "web/views/products/update.ejs" file.`,
options
).toBe("Fill the form below to update this product");
});
});
describe("Testing the create product page form", () => {
it("should have the correct form fields", async () => {
const inputs = await page.$$("input");
expect(
inputs.length,
`The number of inputs in the create form is wrong, it should be 2`,
options
).toBe(2);
const textarea = await page.$$("textarea");
expect(
textarea.length,
`The number of textarea in the create form is wrong, it should be 1`,
options
).toBe(1);
const button = await page.$eval(
"form > button.btn.btn-primary",
(el) => el.textContent
);
expect(
button,
`The submit button is not present on the page, You can change it in the "web/views/products/update.ejs" file.`,
options
).toBeTruthy();
expect(
button,
`The submit button should have the text "Update", but it has "${button}", You can change it in the "web/views/products/update.ejs" file.`,
options
).toBe("Update");
});
it("should the right inputs names and types", async () => {
const name_input = await page.$eval("#name", (el) => ({
name: el.name,
type: el.type,
value: el.value,
}));
expect(
name_input.name,
`The name input should have the name "name", but it has "${name_input.name}", You can change it in the "web/views/products/update.ejs" file.`,
options
).toBe("name");
expect(
name_input.type,
`The name input should have the type "text", but it has "${name_input.type}", You can change it in the "web/views/products/update.ejs" file.`,
options
).toBe("text");
expect(
name_input.value,
`The name input should have the value "${product.name}", but it has "${name_input.value}", You can change it in the "web/views/products/update.ejs" file.`,
options
).toBe(product.name);
const price_input = await page.$eval("#price", (el) => ({
name: el.name,
type: el.type,
value: el.value,
}));
expect(
price_input.name,
`The price input should have the name "price", but it has "${price_input.name}", You can change it in the "web/views/products/update.ejs" file.`,
options
).toBe("price");
expect(
price_input.type,
`The price input should have the type "number", but it has "${price_input.type}", You can change it in the "web/views/products/update.ejs" file.`,
options
).toBe("number");
expect(
price_input.value,
`The price input should have the value "${product.price}", but it has "${price_input.value}", You can change it in the "web/views/products/update.ejs" file.`,
options
).toBe(product.price.toString());
const description_input = await page.$eval("#description", (el) => ({
name: el.name,
rows: el.rows,
value: el.textContent.trim(),
}));
expect(
description_input.name,
`The description textarea should have the name "description", but it has "${description_input.name}", You can change it in the "web/views/products/update.ejs" file.`,
options
).toBe("description");
expect(
description_input.rows,
`The description textarea should have "5" rows "number", but it has "${description_input.rows}", You can change it in the "web/views/products/update.ejs" file.`,
options
).toBe(5);
expect(
description_input.value,
`The description textarea should have the value "${product.description}", but it has "${description_input.value}", You can change it in the "web/views/products/update.ejs" file.`,
options
).toBe(product.description);
});
it("should have the correct form action and method", async () => {
const form = await page.$eval("form", (el) => ({
action: el.action,
method: el.method,
}));
expect(
form.action,
`The form action should be "http://localhost:${process.env.PORT}/products/update/${product.slug}", but it has "${form.action}", You can change it in the "web/views/products/update.ejs" file.`,
options
).toBe(
`http://localhost:${process.env.PORT}/products/update/${product.slug}`
);
expect(
form.method,
`The form method should be "POST", but it has "${form.method}", You can change it in the "web/views/products/update.ejs" file.`,
options
).toBe("post");
});
});
describe("Testing the create product page form submission", () => {
it("should update the product", async () => {
let nameInput = await page.$("#name");
let priceInput = await page.$("#price");
let descriptionInput = await page.$("#description");
await nameInput.click({ clickCount: 3 });
await nameInput.press("Backspace");
await priceInput.click({ clickCount: 3 });
await priceInput.press("Backspace");
await descriptionInput.click({ clickCount: 3 });
await descriptionInput.press("Backspace");
await nameInput.type("Updated product");
await priceInput.type("99.99");
await descriptionInput.type("Updated description");
await page.click("form > button.btn.btn-primary");
await new Promise((resolve) => setTimeout(resolve, 1000));
const url = await page.url();
expect(
url,
`The page url should be "http://localhost:${process.env.PORT}/products/update/${product.slug}", but it has "${url}", You can change it in the "controllers/web/products.controller.js" file.`,
options
).toBe(
`http://localhost:${process.env.PORT}/products/update/${product.slug}`
);
const message = await page.$eval("p.message", (el) => el.textContent);
expect(
message,
`The message should be "Product updated", but it has "${message}", You can change it in the "controllers/web/products.controller.js" file.`,
options
).toBe("Product updated");
const productName = await page.$eval(
".card-title",
(el) => el.textContent
);
const productPrice = await page.$eval(
".card-subtitle",
(el) => el.textContent
);
const productDescription = await page.$eval(
".card-text",
(el) => el.textContent
);
expect(
productName,
`The product after updated should have the new updated name, but it has "${productName}", You can change the update method in the "controllers/web/products.controller.js" file.`,
options
).toBe("Updated product");
expect(
productPrice,
`The product after updated should have the new updated price, but it has "${productPrice}", You can change the update method in the "controllers/web/products.controller.js" file.`,
options
).toBe("$99");
expect(
productDescription,
`The product after updated should have the new updated description, but it has "${productDescription}", You can change the update method in the "controllers/web/products.controller.js" file.`,
options
).toBe("Updated description");
});
it("should not update the product if the name is empty", async () => {
let nameInput = await page.$("#name");
await nameInput.click({ clickCount: 3 });
await nameInput.press("Backspace");
await page.click("form > button.btn.btn-primary");
await new Promise((resolve) => setTimeout(resolve, 1000));
const message = await page.$eval("p.message", (el) => el.textContent);
expect(
message,
`The message received "${message}" of the page is not correct, it should be "Please fill all fields". Change the message of the page to match the expected one. You can change it in the "controllers/web/product.controller.js" file.`,
options
).toBe("Please fill all fields");
});
it("should don't update the product if the product does not exist", async () => {
await page.goto(
`http://localhost:${process.env.PORT}/products/update/123thisproductdoenotexist`
);
const title = await page.title();
expect(
title,
`The title for the web page "${title}" is wrong it should be "API-Experiment | Error" Make sure that the function handling the GET updateProduct method return the error title if the product was not found`,
options
).toBe("API-Experiment | Error");
const statusCode = await page.$eval(".title", (el) => el.textContent);
expect(
statusCode,
`The status code "${statusCode}" is wrong it should be "404" Make sure that the function handling the GET updateProduct method return the error status code if the product was not found`,
options
).toBe("404");
const message = await page.$eval(".message", (el) => el.textContent);
expect(
message,
`The message "${message}" is wrong it should be "No product found" Make sure that the function handling the GET updateProduct method return the error message if the product was not found`,
options
).toBe("No product found");
});
});
describe("Testing the update product page image snapshot", () => {
it("should match the update product page image snapshot", async () => {
let nameInput = await page.$("#name");
let priceInput = await page.$("#price");
let descriptionInput = await page.$("#description");
await nameInput.click({ clickCount: 3 });
await nameInput.press("Backspace");
await priceInput.click({ clickCount: 3 });
await priceInput.press("Backspace");
await descriptionInput.click({ clickCount: 3 });
await descriptionInput.press("Backspace");
if (!fs.existsSync("tests/web/images/update-product-page.png")) {
throw new Error(
`The reference image for the update product page does not exist, please import the image from the "tests/web/images/update-product-page.png"`
);
}
const image = await page.screenshot({ fullPage: true });
expect(
image,
`The image for the update product page is wrong, it should be the same as the "tests/web/images/__diff_output__/update-product-page-diff.png" image`
).toMatchImageSnapshot({
customDiffConfig: { threshold: 0.9 },
customSnapshotsDir: "tests/web/images",
customSnapshotIdentifier: "update-product-page",
});
});
});

View File

@ -0,0 +1,141 @@
const fs = require("fs");
const puppeteer = require("puppeteer");
const { toMatchImageSnapshot } = require("jest-image-snapshot");
const initial_data = JSON.parse(fs.readFileSync("./initial_data.json"));
const mongoose = require("mongoose");
expect.extend({ toMatchImageSnapshot });
require("dotenv").config();
const options = {
showPrefix: false,
showMatcherMessage: true,
showStack: true,
};
let browser;
let page;
let product;
beforeAll(async () => {
browser = await puppeteer.launch({
headless: true,
slowMo: 0,
devtools: false,
defaultViewport: {
width: 1024,
height: 768,
},
});
page = await browser.newPage();
await page.setDefaultTimeout(10000);
await page.setDefaultNavigationTimeout(20000);
});
beforeEach(async () => {
mongoose.set("strictQuery", true);
await mongoose.connect(process.env.MONGODB_URI, {
useNewUrlParser: true,
useUnifiedTopology: true,
});
await mongoose.connection.collection("products").deleteMany({});
initial_data.forEach((product) => {
delete product._id;
delete product.createdAt;
delete product.updatedAt;
});
await mongoose.connection.collection("products").insertMany(initial_data);
await mongoose.connection.close();
await page.goto(`http://localhost:${process.env.PORT}/products`);
await page.click("tbody tr:first-child a");
const url = await page.url();
const slug = url.split("/show/").pop();
product = initial_data.find((product) => product.slug === slug);
});
afterAll(async () => {
await browser.close();
});
describe("Testing the delete form in the details page", () => {
it("should delete a product", async () => {
await page.click("form > button.btn");
await new Promise((resolve) => setTimeout(resolve, 1000));
const url = await page.url();
expect(
url,
`The url for deleting "${url}" a product is not correct, it should be "http://localhost:${process.env.PORT}/products/delete/${product.slug}"`,
options
).toBe(
`http://localhost:${process.env.PORT}/products/delete/${product.slug}`
);
const message = await page.$eval("p.message", (el) => el.textContent);
expect(
message,
`The message for deleting "${message}" a product is not correct, it should be "Product deleted"`,
options
).toBe("Product deleted");
await page.goto(`http://localhost:${process.env.PORT}/products`);
const products_data = await page.$$eval("tbody tr", (rows) =>
rows.map((row) => {
const [no, name, price, description, slug] = row.children;
return {
name: name.textContent,
price: parseFloat(price.textContent.replace("$", "")),
description: description.textContent,
slug: slug.children[0].href.split("/show/").pop(),
};
})
);
initial_data.forEach((product) => {
delete product._id;
delete product.createdAt;
delete product.updatedAt;
});
products_data.sort((a, b) => a.name.localeCompare(b.name));
initial_data.sort((a, b) => a.name.localeCompare(b.name));
expect(
products_data,
`The deleted product should not be in the list of products`,
options
).not.toContainEqual(product);
});
it("should don't delete the product if the product does not exist", async () => {
await page.setRequestInterception(true);
page.on("request", (interceptedRequest) => {
var data = {
method: "POST",
};
interceptedRequest.continue(data);
});
await page.goto(
`http://localhost:${process.env.PORT}/products/delete/1234567890`
);
const title = await page.title();
expect(
title,
`The title for the web page "${title}" is wrong it should be "API-Experiment | Error" Make sure that the function handling the POST deleteProduct method return the error title if the product was not found`,
options
).toBe("API-Experiment | Error");
const statusCode = await page.$eval(".title", (el) => el.textContent);
expect(
statusCode,
`The status code "${statusCode}" is wrong it should be "404" Make sure that the function handling the POST deleteProduct method return the error status code if the product was not found`,
options
).toBe("404");
const message = await page.$eval(".message", (el) => el.textContent);
expect(
message,
`The message "${message}" is wrong it should be "No product found" Make sure that the function handling the POST deleteProduct method return the error message if the product was not found`,
options
).toBe("No product found");
});
});

View File

@ -1,6 +1,6 @@
<div class="flex flex-col max-w-sm rounded-lg overflow-hidden shadow-lg bg-white dark:bg-gray-900 h-full">
<img class="w-40 mx-auto my-4" src="{{$project->getImageAttribute()}}" alt="Project {{$project->title}}"
onerror="this.onerror=null;this.src='{{asset('placeholder.png')}}';">
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">

View File

@ -9,19 +9,19 @@
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8 space-y-6">
<div class="p-4 sm:p-8 bg-white dark:bg-gray-800 shadow sm:rounded-lg">
<div class="max-w-xl">
@include('profile.partials.update-profile-information-form')
@include('nodejs.profile.partials.update-profile-information-form')
</div>
</div>
<div class="p-4 sm:p-8 bg-white dark:bg-gray-800 shadow sm:rounded-lg">
<div class="max-w-xl">
@include('profile.partials.update-password-form')
@include('nodejs.profile.partials.update-password-form')
</div>
</div>
<div class="p-4 sm:p-8 bg-white dark:bg-gray-800 shadow sm:rounded-lg">
<div class="max-w-xl">
@include('profile.partials.delete-user-form')
@include('nodejs.profile.partials.delete-user-form')
</div>
</div>
</div>

View File

@ -1,6 +1,6 @@
<div class="flex flex-col max-w-sm rounded-lg overflow-hidden shadow-lg bg-white dark:bg-gray-900 h-full">
<img class="w-40 mx-auto my-4" src="{{$project->getImageAttribute()}}" alt="Project {{$project->title}}"
onerror="this.onerror=null;this.src='{{asset('placeholder.png')}}';">
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">

View File

@ -26,7 +26,7 @@ class="inline-block bg-gray-200 dark:bg-gray-700 rounded-full px-3 py-1 text-sm
</div>
<div>
<img class="w-40 mx-auto my-4" src="{{$project->getImageAttribute()}}" alt="Project {{$project->title}}"
onerror="this.onerror=null;this.src='{{asset('placeholder.png')}}';">
onerror="this.onerror=null;this.src='{{ asset('assets/nodejs/placeholder.png') }}';">
</div>
</div>
</div>

View File

@ -20,8 +20,7 @@
<div class="mt-4">
<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" />
: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">
@ -53,16 +52,16 @@
url: url,
process: {
headers: {
'X-CSRF-TOKEN': '{{ csrf_token()}}'
'X-CSRF-TOKEN': '{{ csrf_token() }}'
}
},
},
allowMultiple: false,
acceptedFileTypes: ['application/x-zip-compressed'],
acceptedFileTypes: ['application/x-zip-compressed', 'application/zip'],
fileValidateTypeDetectType: (source, type) =>
new Promise((resolve, reject) => {
resolve(type);
}),
resolve(type);
}),
});
pond.on('addfile', function() {
if (pond.getFiles().length > 0) {
@ -77,16 +76,16 @@
url: url,
process: {
headers: {
'X-CSRF-TOKEN': '{{ csrf_token()}}'
'X-CSRF-TOKEN': '{{ csrf_token() }}'
}
},
},
allowMultiple: false,
acceptedFileTypes: ['application/x-zip-compressed'],
acceptedFileTypes: ['application/x-zip-compressed', 'application/zip'],
fileValidateTypeDetectType: (source, type) =>
new Promise((resolve, reject) => {
resolve(type);
}),
resolve(type);
}),
});
});
@ -103,16 +102,16 @@
url: url,
process: {
headers: {
'X-CSRF-TOKEN': '{{ csrf_token()}}'
'X-CSRF-TOKEN': '{{ csrf_token() }}'
}
},
},
allowMultiple: false,
acceptedFileTypes: ['application/x-zip-compressed'],
acceptedFileTypes: ['application/x-zip-compressed', 'application/zip'],
fileValidateTypeDetectType: (source, type) =>
new Promise((resolve, reject) => {
resolve(type);
}),
resolve(type);
}),
});
});
@ -120,8 +119,10 @@
$('.filepond--credits').hide();
$('.filepond--panel-root').addClass('bg-gray-900 ');
$('.filepond--drop-label').addClass('border-2 border-gray-300 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 focus:border-secondary-500 dark:focus:border-secondary-600 focus:ring-secondary-500 dark:focus:ring-secondary-600 rounded-md shadow-sm ');
$('.filepond--drop-label').addClass(
'border-2 border-gray-300 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 focus:border-secondary-500 dark:focus:border-secondary-600 focus:ring-secondary-500 dark:focus:ring-secondary-600 rounded-md shadow-sm '
);
$('form').on('submit', function(e) {
e.preventDefault();
if (pond.getFiles().length > 0) {
@ -149,7 +150,8 @@
button: "Ok",
}).then(function() {
const submission_id = data.submission.id;
window.location = "/nodejs/submissions/submission/" + submission_id;
window.location = "/nodejs/submissions/submission/" +
submission_id;
});
},
error: function(data) {
@ -203,7 +205,8 @@
button: "Ok",
}).then(function() {
const submission_id = data.submission.id;
window.location = "/nodejs/submissions/submission/" + submission_id;
window.location = "/nodejs/submissions/submission/" +
submission_id;
});
},
error: function(data) {
@ -217,9 +220,9 @@
}
});
}
});
});
}
}
});
</script>
</x-app-layout>
</x-app-layout>

File diff suppressed because it is too large Load Diff