MUHAMMAD
FARI
MADYAN
[ Press ESC or Click to Skip ]

Rebuild Ruby on Rails + React E-Learning

1

Part 1: Pengenalan & Arsitektur: Dari Monolit ke Arsitektur Multithreaded Terpisah

2

Part 2: Penerapan Clean Architecture di Ruby on Rails & React: Repository Pattern, Use Cases, Result Monad & Typed Services (PR #1)

3

Part 3: Engine Ujian CBT Presisi Tinggi & Telemetri Pengawasan Real-Time Action Cable

4

Part 4: Pipeline Data Berkecepatan Tinggi dengan Roo & Penguatan Keamanan DevSecOps

5

Part 5: Single VPS Production Deployment: Docker Compose, Tuning Multithreading Puma & Monitoring Pemulihan Otomatis

Artikel ini tersedia dalam Bahasa Inggris

🇬🇧 Read in English
🇮🇩 Bahasa Indonesia📚 Rebuild Ruby on Rails + React E-Learning

Ruby on Rails 8 & React E-Learning Part 3: Engine Ujian CBT Presisi Tinggi & Telemetri Pengawasan Real-Time Action Cable

Rancang engine ujian Computer Based Test (CBT) tangguh anti-curang berbasis Ruby on Rails 8 API dan React 19: Clean Architecture Use Cases, countdown timer otoritatif server, autosave instan, dan telemetri pengawasan siswa real-time via Action Cable & Redis.

Muhammad Fari MadyanPenulis

7 menit baca

·

14 Oktober 2026


Realitas Rekayasa Sistem Ujian Online Berskala Besar

Dalam pelaksanaan ujian akademik berisiko tinggi (high-stakes exams), engine Computer Based Testing (CBT) berhadapan dengan kondisi operasional ekstrem yang jarang ditemui pada aplikasi bisnis umum:

  1. Manipulasi Jam Klien: Peserta ujian dapat dengan mudah mengubah jam sistem operasi laptop, membekukan event loop JavaScript, atau mengotak-atik local storage untuk memperpanjang waktu ujian secara curang.
  2. Volatilitas Jaringan Lab Komputer: Jika router Wi-Fi sekolah mengalami packet loss, mengirimkan seluruh 50 jawaban sekaligus dalam satu request HTTP POST di detik-detik akhir ujian adalah resep bencana kehilangan data massal.
  3. Telemetri Pengawasan Berskala Ratusan Peserta: Guru dan panitia pengawas ujian membutuhkan visibilitas seketika atas aktivitas siswa (nomor soal aktif, sisa waktu, dan counter pelanggaran) tanpa membuat database kewalahan akibat polling HTTP setiap 2 detik.

Melanjutkan fondasi Clean Architecture yang telah kita bangun pada Part 2, pada proyek mfarim/ruby-rails-elearning-react, kami merancang engine CBT menggunakan Use Cases berprinsip single responsibility, perhitungan waktu mutlak server, dan siaran telemetri real-time via Action Cable.

┌────────────────────────────────────────────────────────────────────────────────────────┐ │ TOPOLOGI ENGINE CBT & TELEMETRI ACTION CABLE │ └────────────────────────────────────────────────────────────────────────────────────────┘ 👨‍🎓 Klien Peserta Ujian (React 19) 👨‍🏫 Layar Pengawas Guru ┌────────────────────────────┐ ┌────────────────────────────┐ │ • Lockdown Mode Fullscreen │ │ • Grid Status Peserta Live │ │ • Listener Tab-Switching │ │ • Progress Bar Real-Time │ │ • Autosave Jawaban Instan │ │ • Peringatan Indikasi Curang│ └─────────────┬──────────────┘ └─────────────▲──────────────┘ │ │ HTTPS │ Jawaban / Pelanggaran WSS │ Siaran Telemetri ▼ │ ┌───────────────────────────────────────────────────────────────┴──────────────┐ │ RUBY ON RAILS 8 API BACKEND (Puma) │ │ │ │ ┌─────────────────────────────────┐ ┌──────────────────────────────────┐ │ │ │ Clean Architecture Use Cases │ │ Action Cable Channel (WSS) │ │ │ │ • Exams::StartExamAttempt │──▶│ • Siaran room ExamChannel │ │ │ │ • Exams::SaveExamAnswer │ │ • Stream telemetri peserta │ │ │ │ • Exams::SubmitExamAttempt │ └──────────────────┬───────────────┘ │ │ └────────────────┬────────────────┘ │ │ └───────────────────┼───────────────────────────────────────┼──────────────────┘ │ │ ▼ ▼ ┌──────────────────────┐ ┌──────────────────────┐ │ PostgreSQL 16 │ │ Redis 7 Pub/Sub │ │ (ExamAttempt/Answers)│ │ (Sinkronisasi Multi)│ └──────────────────────┘ └──────────────────────┘

1. Triad Use Cases CBT Berbasis Clean Architecture

Daripada membiarkan controller mengeksekusi operasi database secara langsung atau membebani model Active Record dengan callback yang berbelit-belit, seluruh domain ujian diorkestrasi melalui Use Cases terisolasi yang mengembalikan objek UseCaseResult standar.

A. Pengatur Waktu Otoritatif Server (Exams::StartExamAttempt)

Ketika siswa memulai ujian melalui endpoint POST /api/v1/exams/:id/start, kelas Exams::StartExamAttempt menginisialisasi sesi dan menghitung sisa durasi murni berdasarkan jam server:

RUBY

# backend/app/use_cases/exams/start_exam_attempt.rb
module Exams
  class StartExamAttempt
    def initialize(
      exam_repo: ExaminationRepository.new,
      attempt_repo: ExamAttemptRepository.new
    )
      @exam_repo = exam_repo
      @attempt_repo = attempt_repo
    end

    def call(exam_id:, student:)
      return UseCaseResult.failure("Data siswa tidak ditemukan", status: :forbidden) unless student

      exam = @exam_repo.find(exam_id)
      attempt = @attempt_repo.find_or_initialize_for(exam, student)

      # 1. Inisialisasi attempt jika baru atau diizinkan mengulang
      if attempt.new_record? || (exam.allow_retry && attempt.status == 'completed')
        attempt.status = 'in_progress'
        attempt.started_at = Time.current
        attempt.remaining_seconds = exam.duration_minutes * 60
        attempt.violations_count = 0
        attempt.save!
      end

      # 2. Pengacakan nomor soal jika diaktifkan
      questions = exam.questions.order(:id).to_a
      questions = questions.shuffle if exam.shuffle_questions

      existing_answers = ExamAnswer.where(exam_attempt: attempt).index_by(&:question_id)

      questions_payload = questions.map do |q|
        options = q.options || []
        options = options.shuffle if exam.shuffle_options
        {
          id: q.id,
          examinationId: q.examination_id,
          questionText: q.question_text,
          questionType: q.question_type,
          options: options,
          points: q.points,
          difficulty: q.difficulty,
          studentAnswer: existing_answers[q.id]&.student_answer,
          imagePath: q.image_path,
          audioPath: q.audio_path
        }
      end

      # 3. Kalkulasi sisa waktu mutlak server
      elapsed = (Time.current - attempt.started_at).to_i
      remaining = [exam.duration_minutes * 60 - elapsed, 0].max

      response_data = {
        attemptId: attempt.id,
        examId: exam.id,
        examTitle: exam.title,
        durationMinutes: exam.duration_minutes,
        remainingSeconds: remaining,
        startedAt: attempt.started_at.iso8601,
        totalQuestions: questions.size,
        questions: questions_payload
      }

      UseCaseResult.success(response_data)
    rescue ActiveRecord::RecordNotFound => e
      UseCaseResult.failure(e.message, status: :not_found)
    end
  end
end

Mengapa Pola Ini Mencegah Manipulasi Jam Klien:

Bahkan jika siswa mematikan sementara koneksi internet atau memundurkan jam sistem operasi mereka, saat halaman dibuka kembali, backend melakukan kalkulasi ulang secara mutlak: $$\text{Sisa Detik} = \max\left(0, (\text{Durasi} \times 60) - (\text{Time.current} - \text{attempt.started_at})\right)$$ Jika sisa waktu bernilai 0, setiap pengiriman jawaban berikutnya akan langsung ditolak oleh backend.


B. Autosave Jawaban Instan (Exams::SaveExamAnswer)

Menyimpan jawaban ujian tidak boleh menjadi operasi massal yang berisiko tinggi. Di Exams::SaveExamAnswer, setiap kali siswa memilih salah satu opsi, jawaban langsung disimpan secara atomik:

RUBY

# backend/app/use_cases/exams/save_exam_answer.rb
module Exams
  class SaveExamAnswer
    def initialize(attempt_repo: ExamAttemptRepository.new)
      @attempt_repo = attempt_repo
    end

    def call(attempt_id:, question_id:, answer:)
      attempt = @attempt_repo.find(attempt_id)
      if attempt.status != 'in_progress'
        return UseCaseResult.failure("Sesi ujian sedang tidak aktif", status: :unprocessable_entity)
      end

      # Operasi Upsert atomik jawaban siswa
      ans = ExamAnswer.find_or_initialize_by(
        exam_attempt: attempt,
        question_id: question_id
      )
      ans.student_answer = answer
      ans.save!

      # Siarkan update progres seketika via Action Cable
      ActionCable.server.broadcast(
        "exam_monitor_#{attempt.examination_id}",
        {
          eventType: "ANSWER_SAVED",
          studentId: attempt.student_id,
          answeredCount: attempt.exam_answers.count
        }
      )

      UseCaseResult.success({ saved: true, questionId: question_id })
    rescue ActiveRecord::RecordNotFound => e
      UseCaseResult.failure(e.message, status: :not_found)
    end
  end
end

C. Penilaian Otomatis & Penandaan Esai (Exams::SubmitExamAttempt)

Ketika ujian selesai atau batas waktu berakhir, Exams::SubmitExamAttempt menghitung nilai, mengevaluasi kelulusan berbasis KKM, serta menandai soal esai yang butuh penilaian manual guru:

RUBY

# backend/app/use_cases/exams/submit_exam_attempt.rb
module Exams
  class SubmitExamAttempt
    def initialize(attempt_repo: ExamAttemptRepository.new)
      @attempt_repo = attempt_repo
    end

    def call(attempt_id:)
      attempt = @attempt_repo.find(attempt_id)
      exam = attempt.examination

      questions = exam.questions.index_by(&:id)
      answers = attempt.exam_answers.includes(:question)

      total_points = questions.values.sum(&:points)
      earned_points = 0.0
      has_essay = false

      # Penilaian otomatis untuk soal objektif
      answers.each do |ans|
        q = ans.question
        next unless q

        if q.question_type == 'multiple_choice' || q.question_type == 'true_false'
          is_correct = ans.student_answer.to_s.strip.downcase == q.correct_answer.to_s.strip.downcase
          ans.update!(
            is_correct: is_correct,
            points_awarded: is_correct ? q.points : 0.0,
            graded_at: Time.current
          )
          earned_points += q.points if is_correct
        elsif q.question_type == 'essay'
          has_essay = true
        end
      end

      score_pct = total_points > 0 ? (earned_points / total_points * 100.0).round(2) : 0.0
      passed = score_pct >= exam.passing_score

      # Jika ada soal esai, status attempt beralih ke 'needs_grading'
      attempt.update!(
        score: score_pct,
        passed: passed,
        finished_at: Time.current,
        status: has_essay ? 'needs_grading' : 'completed',
        remaining_seconds: 0
      )

      payload = {
        score: attempt.score,
        passed: attempt.passed,
        status: attempt.status,
        totalQuestions: questions.size,
        answeredCount: answers.size
      }

      UseCaseResult.success(payload)
    rescue ActiveRecord::RecordNotFound => e
      UseCaseResult.failure(e.message, status: :not_found)
    end
  end
end

2. Deteksi Kecurangan & Penguncian Otomatis

Pada backend/app/controllers/api/v1/exam_runner_controller.rb, server mencatat setiap indikasi pelanggaran yang dilaporkan dari browser siswa:

RUBY

# backend/app/controllers/api/v1/exam_runner_controller.rb
def violation
  require_student!
  return if performed?

  attempt = exam_attempt_repository.find(params[:attempt_id])
  attempt.increment!(:violations_count)

  # Diskualifikasi otomatis jika pelanggaran mencapai ambang batas 5x
  force_submitted = false
  if attempt.violations_count >= 5
    attempt.update!(status: 'force_finished', finished_at: Time.current)
    force_submitted = true
  end

  # Kirim peringatan ke pengawas via Action Cable
  ActionCable.server.broadcast(
    "exam_monitor_#{attempt.examination_id}",
    {
      eventType: "VIOLATION_LOGGED",
      studentId: attempt.student_id,
      violationsCount: attempt.violations_count,
      forceSubmitted: force_submitted
    }
  )

  json_response(
    data: { violations: attempt.violations_count, forceSubmitted: force_submitted },
    message: "Pelanggaran tercatat"
  )
end

Event Hook Klien Ujian (ExamRunner.tsx)

Di frontend React, listener memantau jika siswa beralih jendela atau keluar dari mode layar penuh:

TYPESCRIPT

// frontend/src/pages/exams/ExamRunner.tsx
useEffect(() => {
  const handleVisibilityChange = () => {
    if (document.hidden) {
      reportViolation("TAB_SWITCH_DETECTED");
    }
  };

  const handleFullscreenChange = () => {
    if (!document.fullscreenElement) {
      reportViolation("FULLSCREEN_EXITED");
    }
  };

  document.addEventListener("visibilitychange", handleVisibilityChange);
  document.addEventListener("fullscreenchange", handleFullscreenChange);

  return () => {
    document.removeEventListener("visibilitychange", handleVisibilityChange);
    document.removeEventListener("fullscreenchange", handleFullscreenChange);
  };
}, []);

3. Telemetri Real-Time dengan Action Cable & Redis

Untuk mendukung pengawasan langsung tanpa membebani database, Rails 8 mengintegrasikan Action Cable secara native. Pada backend/config/cable.yml:

YAML

# backend/config/cable.yml
development:
  adapter: async

test:
  adapter: test

production:
  adapter: redis
  url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %>
  channel_prefix: elearning_production

Di production, Action Cable menggunakan Redis Pub/Sub sebagai backplane. Ketika worker Puma menyimpan jawaban atau mencatat pelanggaran, event tersebut dipublikasikan ke Redis dan disiarkan seketika ke seluruh pengawas yang membuka ExamMonitor.tsx:

TYPESCRIPT

// frontend/src/pages/exams/ExamMonitor.tsx
useEffect(() => {
  const cable = createConsumer(`wss://${window.location.host}/cable`);
  const subscription = cable.subscriptions.create(
    { channel: "ExamChannel", exam_id: examId },
    {
      received(data) {
        if (data.eventType === "VIOLATION_LOGGED") {
          flashCandidateViolation(data.studentId, data.violationsCount);
        } else if (data.eventType === "ANSWER_SAVED") {
          updateProgressBar(data.studentId, data.answeredCount);
        }
      }
    }
  );

  return () => subscription.unsubscribe();
}, [examId]);

Ringkasan Rekomendasi Arsitektural

  1. Pisahkan alur kerja domain dari lapisan HTTP: Dengan merancang StartExamAttempt, SaveExamAnswer, dan SubmitExamAttempt sebagai Use Cases mandiri, logika bisnis ujian dapat diuji dalam hitungan milidetik secara terisolasi menggunakan unit test RSpec.
  2. Jangan pernah mengkalkulasi batas waktu di sisi klien: Simpan timestamp UTC started_at yang mutlak pada database dan hitung sisa durasi secara dinamis di server pada setiap interaksi.
  3. Manfaatkan Action Cable dan Redis untuk efisiensi pengawasan: Mengeliminasi polling HTTP setiap 2 detik dari ratusan siswa menghemat konsumsi koneksi database hingga lebih dari 90%.

Pada Part 4, kita akan membedah pengolahan data massal dan penguatan DevSecOps: Pipeline Streaming Berkas Spreadsheet dengan Gem Roo, 1-Click Admin Impersonation, serta Audit Keamanan Otomatis Menggunakan Brakeman & Bundler-Audit.

Lanjut Membaca

Previous article thumbnail

← Artikel Sebelumnya

Ruby on Rails 8 + React E-Learning: Clean Architecture Implementation, Repository Pattern, Use Cases & Typed Frontend Services (PR #1 Breakdown)

Artikel Selanjutnya →

Ruby on Rails 8 & React E-Learning Part 4: High-Throughput Data Ingestion with Roo & DevSecOps Security Hardening

Next article thumbnail