MUHAMMAD
FARI
MADYAN
[ Press ESC or Click to Skip ]

Ruby on Rails + React E-Learning Rebuild

1

Part 1: Introduction & Architecture: Monolith to Decoupled Multithreaded Services

2

Part 2: Clean Architecture in Ruby on Rails & React: Repository Pattern, Use Cases, Result Monads & Typed Frontend Services (PR #1)

3

Part 3: High-Precision CBT Exam Engine & Real-Time Action Cable Proctoring

4

Part 4: High-Throughput Data Ingestion with Roo & DevSecOps Security Hardening

5

Part 5: Single VPS Production Deployment: Docker Compose, Puma Multithreading & Auto-Recovery

This article is available in Indonesian

๐Ÿ‡ฎ๐Ÿ‡ฉ Baca dalam Bahasa Indonesia
๐Ÿ‡ฌ๐Ÿ‡ง English๐Ÿ“š Ruby on Rails + React E-Learning Rebuild

Ruby on Rails 8 & React E-Learning Part 3: High-Precision CBT Exam Engine & Real-Time Action Cable Proctoring

Engineer a high-concurrency, cheat-resistant Computer Based Test (CBT) engine using Ruby on Rails 8 API and React 19: Clean Architecture Use Cases, server-authoritative countdown timers, instant autosave, and real-time proctoring telemetry via Action Cable and Redis.

Muhammad Fari MadyanAuthor

7 min read

ยท

Oct 14, 2026


The Engineering Realities of Online Examinations

In high-stakes academic examinations, Computer Based Testing (CBT) engines face hostile operating conditions that traditional enterprise business software never encounters:

  1. Client Clock Tampering: Candidates can modify browser clocks, freeze JavaScript event loops, or manipulate local storage to artificially prolong testing time.
  2. Network Volatility in School Labs: If a local Wi-Fi router experiences intermittent packet loss, submitting an entire 50-question exam in a single final POST request invites catastrophic data loss.
  3. Real-Time Proctoring at Scale: Teachers and invigilators supervising 200+ candidates simultaneously require live telemetry (current question index, remaining time, violation counters) without hammering the server through aggressive 2-second HTTP polling.

Following the Clean Architecture foundation established in Part 2 of this series, we engineered our CBT engine in mfarim/ruby-rails-elearning-react using decoupled, single-responsibility Use Cases, server-authoritative timekeeping, and Action Cable real-time broadcasting.

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ CBT EXAM RUNNER & ACTION CABLE TOPOLOGY โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ ๐Ÿ‘จโ€๐ŸŽ“ Candidate Client (React 19) ๐Ÿ‘จโ€๐Ÿซ Teacher Proctor Screen โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ โ€ข Fullscreen Lockdown โ”‚ โ”‚ โ€ข Real-time Candidate Grid โ”‚ โ”‚ โ€ข Tab-Switch Event Listenerโ”‚ โ”‚ โ€ข Dynamic Progress Bars โ”‚ โ”‚ โ€ข Debounced Autosave Sync โ”‚ โ”‚ โ€ข Live Anti-Cheat Badges โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ฒโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ โ”‚ HTTPS โ”‚ Answers / Violations WSS โ”‚ Telemetry Broadcast โ–ผ โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ RUBY ON RAILS 8 API BACKEND (Puma) โ”‚ โ”‚ โ”‚ โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ โ”‚ โ”‚ Clean Architecture Use Cases โ”‚ โ”‚ Action Cable Channel (WSS) โ”‚ โ”‚ โ”‚ โ”‚ โ€ข Exams::StartExamAttempt โ”‚โ”€โ”€โ–ถโ”‚ โ€ข ExamChannel room broadcast โ”‚ โ”‚ โ”‚ โ”‚ โ€ข Exams::SaveExamAnswer โ”‚ โ”‚ โ€ข Candidate telemetry stream โ”‚ โ”‚ โ”‚ โ”‚ โ€ข Exams::SubmitExamAttempt โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ โ”‚ โ–ผ โ–ผ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ PostgreSQL 16 โ”‚ โ”‚ Redis 7 Pub/Sub โ”‚ โ”‚ (ExamAttempt/Answers)โ”‚ โ”‚ (Action Cable Sync) โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

1. Clean Architecture CBT Use Cases

Rather than letting controllers execute database operations directly or stuffing Active Record models with sprawling callbacks, the exam domain is orchestrated through isolated Use Cases returning standardized UseCaseResult objects.

A. Authoritative Server Timekeeper (Exams::StartExamAttempt)

When a candidate starts an exam via POST /api/v1/exams/:id/start, Exams::StartExamAttempt initializes the session and computes remaining duration strictly based on the server's master clock:

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("Student record not found", status: :forbidden) unless student

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

      # 1. Initialize attempt on new record or allowed retries
      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. Randomize question order if configured
      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. Server-authoritative remaining time calculation
      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

Why This Prevents Clock Manipulation:

Even if a candidate pauses browser execution or manipulates their local system clock, reopening the exam triggers a server-side recalculation: $$\text{Remaining Seconds} = \max\left(0, (\text{Duration} \times 60) - (\text{Time.current} - \text{attempt.started_at})\right)$$ If the delta reaches zero, any subsequent answer submission is immediately rejected by the backend.


B. Instant Autosave (Exams::SaveExamAnswer)

Submitting an exam should not be a risky all-at-once operation. In Exams::SaveExamAnswer, every option selection is persisted atomically:

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("Exam attempt is not in progress", status: :unprocessable_entity)
      end

      # Atomic Upsert of student answer
      ans = ExamAnswer.find_or_initialize_by(
        exam_attempt: attempt,
        question_id: question_id
      )
      ans.student_answer = answer
      ans.save!

      # Push live progress update to 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. Automated Scoring & Essay Flagging (Exams::SubmitExamAttempt)

When the exam concludes (or the hard deadline expires), Exams::SubmitExamAttempt computes scores, evaluates passing status (KKM), and routes essay questions for teacher grading:

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

      # Grade objective questions automatically
      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

      # If essay questions exist, status transitions to '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. Anti-Cheat Violation Trapping

In backend/app/controllers/api/v1/exam_runner_controller.rb, we intercept cheating infractions detected on the candidate's browser:

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)

  # Hard auto-submit when violations hit threshold
  force_submitted = false
  if attempt.violations_count >= 5
    attempt.update!(status: 'force_finished', finished_at: Time.current)
    force_submitted = true
  end

  # Broadcast violation alert to invigilators 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: "Violation recorded"
  )
end

Candidate Client Lockdown (ExamRunner.tsx)

On the React frontend, listeners detect if the student leaves fullscreen or switches tabs:

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. Real-Time Telemetry via Action Cable & Redis

To support live supervision without database polling, Rails 8 integrates Action Cable seamlessly. In 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

In production, Action Cable uses Redis Pub/Sub as the backplane. When a Puma worker saves an answer or logs a violation, it publishes the event to Redis, which immediately broadcasts it to all connected teachers viewing 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]);

Key Takeaways for Engineering Leads

  1. Decouple domain workflows from HTTP transport: By implementing StartExamAttempt, SaveExamAnswer, and SubmitExamAttempt as standalone Use Cases, exam business logic can be tested in sub-second isolation using in-memory RSpec unit tests.
  2. Never calculate deadlines client-side: Store an immutable UTC started_at timestamp on the attempt and compute remaining seconds dynamically on the server.
  3. Use Action Cable with Redis for scalable proctoring: Eliminating 2-second HTTP polling from 500 active candidates reduces database connection saturation by over 90%.

In Part 4, we explore data ingestion and DevSecOps: High-Throughput Spreadsheet Parsing with the roo Gem, Stateless JWT Admin Impersonation, and Automated Security Auditing with Brakeman & Bundler-Audit.

Continue Reading

Previous article

โ† Previous Article

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

Next Article โ†’

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

Next article