mirror of https://github.com/docusealco/docuseal
- Add SoftDeletable concern for reusable soft delete functionality - Create FeatureFlagCheck controller concern - Add migration for feature_flags table with default seeds - Enhance Institution, Cohort, CohortEnrollment models - Implement AASM state machine for Cohort (draft→active→completed) - Add comprehensive model specs (900+ lines) - Add concern specs and integration specs - Add factory definitions - Update Gemfile with AASM gem Status: Implementation Complete - Pending Test Execution All files created and tested in design. Ready for local testing.pull/565/head
parent
9d85b0d38b
commit
03039650ab
@ -0,0 +1,43 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# FeatureFlagCheck Concern
|
||||||
|
# Purpose: Provides before_action helpers to check feature flags in controllers
|
||||||
|
# Usage: include FeatureFlagCheck in controllers that need feature flag protection
|
||||||
|
module FeatureFlagCheck
|
||||||
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
|
included do
|
||||||
|
# Helper method available in controllers
|
||||||
|
end
|
||||||
|
|
||||||
|
class_methods do
|
||||||
|
# Add a before_action to require a feature flag
|
||||||
|
# @param feature_name [Symbol, String] the name of the feature flag
|
||||||
|
# @param options [Hash] options to pass to before_action
|
||||||
|
# @example
|
||||||
|
# before_action :require_feature(:flodoc_cohorts)
|
||||||
|
def require_feature(feature_name, **options)
|
||||||
|
before_action(**options) do
|
||||||
|
check_feature_flag(feature_name)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
# Check if a feature flag is enabled, render 404 if not
|
||||||
|
# @param feature_name [Symbol, String] the name of the feature flag
|
||||||
|
def check_feature_flag(feature_name)
|
||||||
|
return if FeatureFlag.enabled?(feature_name)
|
||||||
|
|
||||||
|
render json: { error: 'Feature not available' }, status: :not_found
|
||||||
|
end
|
||||||
|
|
||||||
|
# Check if a feature is enabled (for use in views/controllers)
|
||||||
|
# @param feature_name [Symbol, String] the name of the feature flag
|
||||||
|
# @return [Boolean] true if enabled
|
||||||
|
def feature_enabled?(feature_name)
|
||||||
|
FeatureFlag.enabled?(feature_name)
|
||||||
|
end
|
||||||
|
helper_method :feature_enabled? if respond_to?(:helper_method)
|
||||||
|
end
|
||||||
@ -0,0 +1,42 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# SoftDeletable Concern
|
||||||
|
# Purpose: Provides soft delete functionality for models
|
||||||
|
# Usage: include SoftDeletable in any model with a deleted_at column
|
||||||
|
module SoftDeletable
|
||||||
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
|
included do
|
||||||
|
# Default scope to exclude soft-deleted records
|
||||||
|
default_scope { where(deleted_at: nil) }
|
||||||
|
|
||||||
|
# Scopes for querying soft-deleted records
|
||||||
|
scope :active, -> { where(deleted_at: nil) }
|
||||||
|
scope :archived, -> { unscope(where: :deleted_at).where.not(deleted_at: nil) }
|
||||||
|
scope :with_archived, -> { unscope(where: :deleted_at) }
|
||||||
|
end
|
||||||
|
|
||||||
|
# Soft delete the record by setting deleted_at timestamp
|
||||||
|
# @return [Boolean] true if successful
|
||||||
|
def soft_delete
|
||||||
|
update(deleted_at: Time.current)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Restore a soft-deleted record
|
||||||
|
# @return [Boolean] true if successful
|
||||||
|
def restore
|
||||||
|
update(deleted_at: nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Check if record is soft-deleted
|
||||||
|
# @return [Boolean] true if deleted_at is present
|
||||||
|
def deleted?
|
||||||
|
deleted_at.present?
|
||||||
|
end
|
||||||
|
|
||||||
|
# Check if record is active (not soft-deleted)
|
||||||
|
# @return [Boolean] true if deleted_at is nil
|
||||||
|
def active?
|
||||||
|
deleted_at.nil?
|
||||||
|
end
|
||||||
|
end
|
||||||
@ -0,0 +1,45 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# == Schema Information
|
||||||
|
#
|
||||||
|
# Table name: feature_flags
|
||||||
|
#
|
||||||
|
# id :bigint not null, primary key
|
||||||
|
# description :text
|
||||||
|
# enabled :boolean default(FALSE), not null
|
||||||
|
# name :string not null
|
||||||
|
# created_at :datetime not null
|
||||||
|
# updated_at :datetime not null
|
||||||
|
#
|
||||||
|
# Indexes
|
||||||
|
#
|
||||||
|
# index_feature_flags_on_name (name) UNIQUE
|
||||||
|
#
|
||||||
|
class FeatureFlag < ApplicationRecord
|
||||||
|
# Validations
|
||||||
|
validates :name, presence: true, uniqueness: true
|
||||||
|
|
||||||
|
# Check if a feature is enabled
|
||||||
|
# @param feature_name [String, Symbol] the name of the feature flag
|
||||||
|
# @return [Boolean] true if the feature is enabled, false otherwise
|
||||||
|
def self.enabled?(feature_name)
|
||||||
|
flag = find_by(name: feature_name.to_s)
|
||||||
|
flag&.enabled || false
|
||||||
|
end
|
||||||
|
|
||||||
|
# Enable a feature flag
|
||||||
|
# @param feature_name [String, Symbol] the name of the feature flag
|
||||||
|
# @return [Boolean] true if successful
|
||||||
|
def self.enable!(feature_name)
|
||||||
|
flag = find_or_create_by(name: feature_name.to_s)
|
||||||
|
flag.update(enabled: true)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Disable a feature flag
|
||||||
|
# @param feature_name [String, Symbol] the name of the feature flag
|
||||||
|
# @return [Boolean] true if successful
|
||||||
|
def self.disable!(feature_name)
|
||||||
|
flag = find_or_create_by(name: feature_name.to_s)
|
||||||
|
flag.update(enabled: false)
|
||||||
|
end
|
||||||
|
end
|
||||||
@ -0,0 +1,25 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# Migration: Create Feature Flags Table
|
||||||
|
# Purpose: Enable/disable FloDoc functionality without code changes
|
||||||
|
# Risk: LOW - Simple table with no foreign keys
|
||||||
|
class CreateFeatureFlags < ActiveRecord::Migration[7.0]
|
||||||
|
def change
|
||||||
|
create_table :feature_flags do |t|
|
||||||
|
t.string :name, null: false, index: { unique: true }
|
||||||
|
t.boolean :enabled, default: false, null: false
|
||||||
|
t.text :description
|
||||||
|
t.timestamps
|
||||||
|
end
|
||||||
|
|
||||||
|
# Seed default feature flags
|
||||||
|
reversible do |dir|
|
||||||
|
dir.up do
|
||||||
|
FeatureFlag.create!([
|
||||||
|
{ name: 'flodoc_cohorts', enabled: true, description: '3-portal cohort management system' },
|
||||||
|
{ name: 'flodoc_portals', enabled: true, description: 'Student and Sponsor portals' }
|
||||||
|
])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@ -0,0 +1,71 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe FeatureFlagCheck, type: :controller do
|
||||||
|
controller(ApplicationController) do
|
||||||
|
include FeatureFlagCheck
|
||||||
|
|
||||||
|
require_feature :test_feature
|
||||||
|
|
||||||
|
def index
|
||||||
|
render json: { message: 'success' }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
before do
|
||||||
|
routes.draw { get 'index' => 'anonymous#index' }
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'require_feature' do
|
||||||
|
context 'when feature is enabled' do
|
||||||
|
before { allow(FeatureFlag).to receive(:enabled?).with(:test_feature).and_return(true) }
|
||||||
|
|
||||||
|
it 'allows the action to proceed' do
|
||||||
|
get :index
|
||||||
|
expect(response).to have_http_status(:success)
|
||||||
|
expect(JSON.parse(response.body)['message']).to eq('success')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when feature is disabled' do
|
||||||
|
before { allow(FeatureFlag).to receive(:enabled?).with(:test_feature).and_return(false) }
|
||||||
|
|
||||||
|
it 'returns 404 not found' do
|
||||||
|
get :index
|
||||||
|
expect(response).to have_http_status(:not_found)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns error message' do
|
||||||
|
get :index
|
||||||
|
expect(JSON.parse(response.body)['error']).to eq('Feature not available')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#feature_enabled?' do
|
||||||
|
controller(ApplicationController) do
|
||||||
|
include FeatureFlagCheck
|
||||||
|
|
||||||
|
def check_feature
|
||||||
|
render json: { enabled: feature_enabled?(:test_feature) }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
before do
|
||||||
|
routes.draw { get 'check_feature' => 'anonymous#check_feature' }
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns true when feature is enabled' do
|
||||||
|
allow(FeatureFlag).to receive(:enabled?).with(:test_feature).and_return(true)
|
||||||
|
get :check_feature
|
||||||
|
expect(JSON.parse(response.body)['enabled']).to be true
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns false when feature is disabled' do
|
||||||
|
allow(FeatureFlag).to receive(:enabled?).with(:test_feature).and_return(false)
|
||||||
|
get :check_feature
|
||||||
|
expect(JSON.parse(response.body)['enabled']).to be false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@ -0,0 +1,76 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# Factory definitions for FloDoc models
|
||||||
|
FactoryBot.define do
|
||||||
|
factory :feature_flag do
|
||||||
|
sequence(:name) { |n| "feature_#{n}" }
|
||||||
|
enabled { false }
|
||||||
|
description { 'Test feature flag' }
|
||||||
|
end
|
||||||
|
|
||||||
|
factory :institution do
|
||||||
|
sequence(:name) { |n| "Institution #{n}" }
|
||||||
|
sequence(:email) { |n| "institution#{n}@example.com" }
|
||||||
|
contact_person { 'John Doe' }
|
||||||
|
phone { '+27123456789' }
|
||||||
|
settings { {} }
|
||||||
|
deleted_at { nil }
|
||||||
|
end
|
||||||
|
|
||||||
|
factory :cohort do
|
||||||
|
association :institution
|
||||||
|
association :template
|
||||||
|
sequence(:name) { |n| "Cohort #{n}" }
|
||||||
|
program_type { 'learnership' }
|
||||||
|
sequence(:sponsor_email) { |n| "sponsor#{n}@example.com" }
|
||||||
|
required_student_uploads { [] }
|
||||||
|
cohort_metadata { {} }
|
||||||
|
status { 'draft' }
|
||||||
|
tp_signed_at { nil }
|
||||||
|
students_completed_at { nil }
|
||||||
|
sponsor_completed_at { nil }
|
||||||
|
finalized_at { nil }
|
||||||
|
deleted_at { nil }
|
||||||
|
|
||||||
|
trait :active do
|
||||||
|
status { 'active' }
|
||||||
|
tp_signed_at { Time.current }
|
||||||
|
end
|
||||||
|
|
||||||
|
trait :completed do
|
||||||
|
status { 'completed' }
|
||||||
|
tp_signed_at { Time.current }
|
||||||
|
students_completed_at { Time.current }
|
||||||
|
sponsor_completed_at { Time.current }
|
||||||
|
finalized_at { Time.current }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
factory :cohort_enrollment do
|
||||||
|
association :cohort
|
||||||
|
association :submission
|
||||||
|
sequence(:student_email) { |n| "student#{n}@example.com" }
|
||||||
|
student_name { 'John' }
|
||||||
|
student_surname { 'Doe' }
|
||||||
|
sequence(:student_id) { |n| "STU#{n.to_s.rjust(6, '0')}" }
|
||||||
|
status { 'waiting' }
|
||||||
|
role { 'student' }
|
||||||
|
uploaded_documents { {} }
|
||||||
|
values { {} }
|
||||||
|
completed_at { nil }
|
||||||
|
deleted_at { nil }
|
||||||
|
|
||||||
|
trait :in_progress do
|
||||||
|
status { 'in_progress' }
|
||||||
|
end
|
||||||
|
|
||||||
|
trait :complete do
|
||||||
|
status { 'complete' }
|
||||||
|
completed_at { Time.current }
|
||||||
|
end
|
||||||
|
|
||||||
|
trait :sponsor do
|
||||||
|
role { 'sponsor' }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@ -0,0 +1,176 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe 'FloDoc Models Integration', type: :integration do
|
||||||
|
describe 'Integration with existing DocuSeal models' do
|
||||||
|
describe 'Cohort and Template integration' do
|
||||||
|
let(:template) { create(:template) }
|
||||||
|
let(:institution) { create(:institution) }
|
||||||
|
|
||||||
|
it 'can reference existing Template model' do
|
||||||
|
cohort = create(:cohort, template: template, institution: institution)
|
||||||
|
expect(cohort.template).to eq(template)
|
||||||
|
expect(cohort.template_id).to eq(template.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'validates presence of template' do
|
||||||
|
cohort = build(:cohort, template: nil, institution: institution)
|
||||||
|
expect(cohort).not_to be_valid
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not modify existing Template model' do
|
||||||
|
expect(Template.column_names).not_to include('cohort_id')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'CohortEnrollment and Submission integration' do
|
||||||
|
let(:submission) { create(:submission) }
|
||||||
|
let(:cohort) { create(:cohort) }
|
||||||
|
|
||||||
|
it 'can reference existing Submission model' do
|
||||||
|
enrollment = create(:cohort_enrollment, submission: submission, cohort: cohort)
|
||||||
|
expect(enrollment.submission).to eq(submission)
|
||||||
|
expect(enrollment.submission_id).to eq(submission.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'validates presence of submission' do
|
||||||
|
enrollment = build(:cohort_enrollment, submission: nil, cohort: cohort)
|
||||||
|
expect(enrollment).not_to be_valid
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'enforces unique submission_id constraint' do
|
||||||
|
create(:cohort_enrollment, submission: submission, cohort: cohort)
|
||||||
|
duplicate = build(:cohort_enrollment, submission: submission, cohort: cohort)
|
||||||
|
expect(duplicate).not_to be_valid
|
||||||
|
expect(duplicate.errors[:submission_id]).to be_present
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not modify existing Submission model' do
|
||||||
|
expect(Submission.column_names).not_to include('cohort_enrollment_id')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'Cohort has_many submissions through cohort_enrollments' do
|
||||||
|
let(:cohort) { create(:cohort) }
|
||||||
|
let(:submission1) { create(:submission) }
|
||||||
|
let(:submission2) { create(:submission) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
create(:cohort_enrollment, cohort: cohort, submission: submission1)
|
||||||
|
create(:cohort_enrollment, cohort: cohort, submission: submission2)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'can access submissions through cohort_enrollments' do
|
||||||
|
expect(cohort.submissions).to include(submission1, submission2)
|
||||||
|
expect(cohort.submissions.count).to eq(2)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'Institution has_many cohorts' do
|
||||||
|
let(:institution) { create(:institution) }
|
||||||
|
let!(:cohort1) { create(:cohort, institution: institution) }
|
||||||
|
let!(:cohort2) { create(:cohort, institution: institution) }
|
||||||
|
|
||||||
|
it 'can access cohorts through institution' do
|
||||||
|
expect(institution.cohorts).to include(cohort1, cohort2)
|
||||||
|
expect(institution.cohorts.count).to eq(2)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'destroys cohorts when institution is destroyed' do
|
||||||
|
expect { institution.destroy }
|
||||||
|
.to change(Cohort, :count).by(-2)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'Cohort has_many cohort_enrollments' do
|
||||||
|
let(:cohort) { create(:cohort) }
|
||||||
|
let!(:enrollment1) { create(:cohort_enrollment, cohort: cohort) }
|
||||||
|
let!(:enrollment2) { create(:cohort_enrollment, cohort: cohort) }
|
||||||
|
|
||||||
|
it 'can access enrollments through cohort' do
|
||||||
|
expect(cohort.cohort_enrollments).to include(enrollment1, enrollment2)
|
||||||
|
expect(cohort.cohort_enrollments.count).to eq(2)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'destroys enrollments when cohort is destroyed' do
|
||||||
|
expect { cohort.destroy }
|
||||||
|
.to change(CohortEnrollment, :count).by(-2)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'Query performance' do
|
||||||
|
let(:institution) { create(:institution) }
|
||||||
|
let(:template) { create(:template) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
# Create test data
|
||||||
|
5.times do
|
||||||
|
cohort = create(:cohort, institution: institution, template: template)
|
||||||
|
10.times do
|
||||||
|
submission = create(:submission)
|
||||||
|
create(:cohort_enrollment, cohort: cohort, submission: submission)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'eager loads associations to avoid N+1 queries' do
|
||||||
|
# Without eager loading - N+1 queries
|
||||||
|
expect do
|
||||||
|
Cohort.all.each do |cohort|
|
||||||
|
cohort.institution.name
|
||||||
|
cohort.template.name
|
||||||
|
end
|
||||||
|
end.to make_database_queries(count: 11..15)
|
||||||
|
|
||||||
|
# With eager loading - fewer queries
|
||||||
|
expect do
|
||||||
|
Cohort.includes(:institution, :template).each do |cohort|
|
||||||
|
cohort.institution.name
|
||||||
|
cohort.template.name
|
||||||
|
end
|
||||||
|
end.to make_database_queries(count: 1..5)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'handles large datasets efficiently' do
|
||||||
|
start_time = Time.current
|
||||||
|
Cohort.includes(:institution, :template, :cohort_enrollments).all.to_a
|
||||||
|
query_time = Time.current - start_time
|
||||||
|
|
||||||
|
# Query should complete in under 1 second for 50 enrollments
|
||||||
|
expect(query_time).to be < 1.0
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'Data integrity' do
|
||||||
|
it 'maintains referential integrity with foreign keys' do
|
||||||
|
institution = create(:institution)
|
||||||
|
cohort = create(:cohort, institution: institution)
|
||||||
|
|
||||||
|
# Cannot delete institution with cohorts (due to dependent: :destroy)
|
||||||
|
expect { institution.destroy }.to change(Cohort, :count).by(-1)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'prevents orphaned cohort_enrollments' do
|
||||||
|
cohort = create(:cohort)
|
||||||
|
enrollment = create(:cohort_enrollment, cohort: cohort)
|
||||||
|
|
||||||
|
# Deleting cohort should delete enrollments
|
||||||
|
expect { cohort.destroy }.to change(CohortEnrollment, :count).by(-1)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'validates foreign key constraints' do
|
||||||
|
# Attempting to create cohort with non-existent institution should fail
|
||||||
|
expect do
|
||||||
|
Cohort.create!(
|
||||||
|
institution_id: 999_999,
|
||||||
|
template_id: create(:template).id,
|
||||||
|
name: 'Test',
|
||||||
|
program_type: 'learnership',
|
||||||
|
sponsor_email: 'test@example.com'
|
||||||
|
)
|
||||||
|
end.to raise_error(ActiveRecord::RecordInvalid)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@ -0,0 +1,226 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe CohortEnrollment, type: :model do
|
||||||
|
describe 'concerns' do
|
||||||
|
it 'includes SoftDeletable' do
|
||||||
|
expect(CohortEnrollment.ancestors).to include(SoftDeletable)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'associations' do
|
||||||
|
it { should belong_to(:cohort) }
|
||||||
|
it { should belong_to(:submission) }
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'validations' do
|
||||||
|
subject { build(:cohort_enrollment) }
|
||||||
|
|
||||||
|
it { should validate_presence_of(:student_email) }
|
||||||
|
it { should validate_uniqueness_of(:submission_id) }
|
||||||
|
it { should validate_inclusion_of(:status).in_array(%w[waiting in_progress complete]) }
|
||||||
|
it { should validate_inclusion_of(:role).in_array(%w[student sponsor]) }
|
||||||
|
|
||||||
|
it 'validates student email format' do
|
||||||
|
enrollment = build(:cohort_enrollment, student_email: 'invalid-email')
|
||||||
|
expect(enrollment).not_to be_valid
|
||||||
|
expect(enrollment.errors[:student_email]).to be_present
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'accepts valid email format' do
|
||||||
|
enrollment = build(:cohort_enrollment, student_email: 'student@example.com')
|
||||||
|
expect(enrollment).to be_valid
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'uniqueness validations' do
|
||||||
|
let(:cohort) { create(:cohort) }
|
||||||
|
let!(:existing_enrollment) do
|
||||||
|
create(:cohort_enrollment, cohort: cohort, student_email: 'student@example.com')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'validates uniqueness of student_email scoped to cohort_id' do
|
||||||
|
duplicate = build(:cohort_enrollment, cohort: cohort, student_email: 'student@example.com')
|
||||||
|
expect(duplicate).not_to be_valid
|
||||||
|
expect(duplicate.errors[:student_email]).to be_present
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'allows same email in different cohorts' do
|
||||||
|
other_cohort = create(:cohort)
|
||||||
|
enrollment = build(:cohort_enrollment, cohort: other_cohort, student_email: 'student@example.com')
|
||||||
|
expect(enrollment).to be_valid
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'validates uniqueness case-insensitively' do
|
||||||
|
duplicate = build(:cohort_enrollment, cohort: cohort, student_email: 'STUDENT@example.com')
|
||||||
|
expect(duplicate).not_to be_valid
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'strip_attributes' do
|
||||||
|
it 'strips whitespace from student_email' do
|
||||||
|
enrollment = create(:cohort_enrollment, student_email: ' student@example.com ')
|
||||||
|
expect(enrollment.student_email).to eq('student@example.com')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'strips whitespace from student_name' do
|
||||||
|
enrollment = create(:cohort_enrollment, student_name: ' John ')
|
||||||
|
expect(enrollment.student_name).to eq('John')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'strips whitespace from student_surname' do
|
||||||
|
enrollment = create(:cohort_enrollment, student_surname: ' Doe ')
|
||||||
|
expect(enrollment.student_surname).to eq('Doe')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'strips whitespace from student_id' do
|
||||||
|
enrollment = create(:cohort_enrollment, student_id: ' 12345 ')
|
||||||
|
expect(enrollment.student_id).to eq('12345')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'strips whitespace from role' do
|
||||||
|
enrollment = create(:cohort_enrollment, role: ' student ')
|
||||||
|
expect(enrollment.role).to eq('student')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'scopes' do
|
||||||
|
let(:cohort) { create(:cohort) }
|
||||||
|
let!(:student_waiting) { create(:cohort_enrollment, cohort: cohort, role: 'student', status: 'waiting') }
|
||||||
|
let!(:student_in_progress) { create(:cohort_enrollment, cohort: cohort, role: 'student', status: 'in_progress') }
|
||||||
|
let!(:student_complete) { create(:cohort_enrollment, cohort: cohort, role: 'student', status: 'complete') }
|
||||||
|
let!(:sponsor_enrollment) { create(:cohort_enrollment, cohort: cohort, role: 'sponsor', status: 'waiting') }
|
||||||
|
let!(:deleted_enrollment) { create(:cohort_enrollment, cohort: cohort, deleted_at: Time.current) }
|
||||||
|
|
||||||
|
describe '.students' do
|
||||||
|
it 'returns only student enrollments' do
|
||||||
|
expect(CohortEnrollment.students).to include(student_waiting, student_in_progress, student_complete)
|
||||||
|
expect(CohortEnrollment.students).not_to include(sponsor_enrollment)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '.sponsor' do
|
||||||
|
it 'returns only sponsor enrollments' do
|
||||||
|
expect(CohortEnrollment.sponsor).to include(sponsor_enrollment)
|
||||||
|
expect(CohortEnrollment.sponsor).not_to include(student_waiting)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '.waiting' do
|
||||||
|
it 'returns only waiting enrollments' do
|
||||||
|
expect(CohortEnrollment.waiting).to include(student_waiting, sponsor_enrollment)
|
||||||
|
expect(CohortEnrollment.waiting).not_to include(student_in_progress, student_complete)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '.in_progress' do
|
||||||
|
it 'returns only in_progress enrollments' do
|
||||||
|
expect(CohortEnrollment.in_progress).to include(student_in_progress)
|
||||||
|
expect(CohortEnrollment.in_progress).not_to include(student_waiting, student_complete)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '.complete' do
|
||||||
|
it 'returns only complete enrollments' do
|
||||||
|
expect(CohortEnrollment.complete).to include(student_complete)
|
||||||
|
expect(CohortEnrollment.complete).not_to include(student_waiting, student_in_progress)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '.active (from SoftDeletable)' do
|
||||||
|
it 'excludes soft-deleted enrollments' do
|
||||||
|
expect(CohortEnrollment.active).not_to include(deleted_enrollment)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#complete!' do
|
||||||
|
let(:enrollment) { create(:cohort_enrollment, status: 'waiting', completed_at: nil) }
|
||||||
|
|
||||||
|
it 'changes status to complete' do
|
||||||
|
expect { enrollment.complete! }
|
||||||
|
.to change(enrollment, :status).from('waiting').to('complete')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'sets completed_at timestamp' do
|
||||||
|
expect { enrollment.complete! }
|
||||||
|
.to change(enrollment, :completed_at).from(nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns true on success' do
|
||||||
|
expect(enrollment.complete!).to be true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#mark_in_progress!' do
|
||||||
|
let(:enrollment) { create(:cohort_enrollment, status: 'waiting') }
|
||||||
|
|
||||||
|
it 'changes status to in_progress' do
|
||||||
|
expect { enrollment.mark_in_progress! }
|
||||||
|
.to change(enrollment, :status).from('waiting').to('in_progress')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns true on success' do
|
||||||
|
expect(enrollment.mark_in_progress!).to be true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#waiting?' do
|
||||||
|
it 'returns true when status is waiting' do
|
||||||
|
enrollment = create(:cohort_enrollment, status: 'waiting')
|
||||||
|
expect(enrollment.waiting?).to be true
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns false when status is not waiting' do
|
||||||
|
enrollment = create(:cohort_enrollment, status: 'complete')
|
||||||
|
expect(enrollment.waiting?).to be false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#completed?' do
|
||||||
|
it 'returns true when status is complete' do
|
||||||
|
enrollment = create(:cohort_enrollment, status: 'complete')
|
||||||
|
expect(enrollment.completed?).to be true
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns false when status is not complete' do
|
||||||
|
enrollment = create(:cohort_enrollment, status: 'waiting')
|
||||||
|
expect(enrollment.completed?).to be false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'soft delete functionality' do
|
||||||
|
let(:enrollment) { create(:cohort_enrollment) }
|
||||||
|
|
||||||
|
it 'soft deletes the record' do
|
||||||
|
expect { enrollment.soft_delete }
|
||||||
|
.to change { enrollment.reload.deleted_at }.from(nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'excludes soft-deleted records from default scope' do
|
||||||
|
enrollment.soft_delete
|
||||||
|
expect(CohortEnrollment.all).not_to include(enrollment)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'restores soft-deleted records' do
|
||||||
|
enrollment.soft_delete
|
||||||
|
expect { enrollment.restore }
|
||||||
|
.to change { enrollment.reload.deleted_at }.to(nil)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'integration with existing models' do
|
||||||
|
it 'can reference existing Submission model' do
|
||||||
|
submission = create(:submission)
|
||||||
|
enrollment = create(:cohort_enrollment, submission: submission)
|
||||||
|
expect(enrollment.submission).to eq(submission)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'belongs to a cohort' do
|
||||||
|
cohort = create(:cohort)
|
||||||
|
enrollment = create(:cohort_enrollment, cohort: cohort)
|
||||||
|
expect(enrollment.cohort).to eq(cohort)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@ -0,0 +1,298 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe Cohort, type: :model do
|
||||||
|
describe 'concerns' do
|
||||||
|
it 'includes SoftDeletable' do
|
||||||
|
expect(Cohort.ancestors).to include(SoftDeletable)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'includes AASM' do
|
||||||
|
expect(Cohort.ancestors).to include(AASM)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'associations' do
|
||||||
|
it { should belong_to(:institution) }
|
||||||
|
it { should belong_to(:template) }
|
||||||
|
it { should have_many(:cohort_enrollments).dependent(:destroy) }
|
||||||
|
it { should have_many(:submissions).through(:cohort_enrollments) }
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'validations' do
|
||||||
|
it { should validate_presence_of(:name) }
|
||||||
|
it { should validate_presence_of(:program_type) }
|
||||||
|
it { should validate_presence_of(:sponsor_email) }
|
||||||
|
it { should validate_inclusion_of(:program_type).in_array(%w[learnership internship candidacy]) }
|
||||||
|
it { should validate_inclusion_of(:status).in_array(%w[draft active completed]) }
|
||||||
|
|
||||||
|
it 'validates sponsor email format' do
|
||||||
|
cohort = build(:cohort, sponsor_email: 'invalid-email')
|
||||||
|
expect(cohort).not_to be_valid
|
||||||
|
expect(cohort.errors[:sponsor_email]).to be_present
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'accepts valid email format' do
|
||||||
|
cohort = build(:cohort, sponsor_email: 'sponsor@example.com')
|
||||||
|
expect(cohort).to be_valid
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'strip_attributes' do
|
||||||
|
it 'strips whitespace from name' do
|
||||||
|
cohort = create(:cohort, name: ' Test Cohort ')
|
||||||
|
expect(cohort.name).to eq('Test Cohort')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'strips whitespace from program_type' do
|
||||||
|
cohort = create(:cohort, program_type: ' learnership ')
|
||||||
|
expect(cohort.program_type).to eq('learnership')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'strips whitespace from sponsor_email' do
|
||||||
|
cohort = create(:cohort, sponsor_email: ' sponsor@example.com ')
|
||||||
|
expect(cohort.sponsor_email).to eq('sponsor@example.com')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'scopes' do
|
||||||
|
let!(:draft_cohort) { create(:cohort, status: 'draft') }
|
||||||
|
let!(:active_cohort) { create(:cohort, status: 'active') }
|
||||||
|
let!(:completed_cohort) { create(:cohort, status: 'completed') }
|
||||||
|
let!(:deleted_cohort) { create(:cohort, deleted_at: Time.current) }
|
||||||
|
|
||||||
|
describe '.draft' do
|
||||||
|
it 'returns only draft cohorts' do
|
||||||
|
expect(Cohort.draft).to include(draft_cohort)
|
||||||
|
expect(Cohort.draft).not_to include(active_cohort, completed_cohort)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '.active_status' do
|
||||||
|
it 'returns only active cohorts' do
|
||||||
|
expect(Cohort.active_status).to include(active_cohort)
|
||||||
|
expect(Cohort.active_status).not_to include(draft_cohort, completed_cohort)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '.completed' do
|
||||||
|
it 'returns only completed cohorts' do
|
||||||
|
expect(Cohort.completed).to include(completed_cohort)
|
||||||
|
expect(Cohort.completed).not_to include(draft_cohort, active_cohort)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '.ready_for_sponsor' do
|
||||||
|
let!(:ready_cohort) do
|
||||||
|
create(:cohort, status: 'active', students_completed_at: Time.current)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns active cohorts with students_completed_at set' do
|
||||||
|
expect(Cohort.ready_for_sponsor).to include(ready_cohort)
|
||||||
|
expect(Cohort.ready_for_sponsor).not_to include(active_cohort)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '.active (from SoftDeletable)' do
|
||||||
|
it 'excludes soft-deleted cohorts' do
|
||||||
|
expect(Cohort.active).not_to include(deleted_cohort)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'state machine' do
|
||||||
|
let(:cohort) { create(:cohort, status: 'draft') }
|
||||||
|
|
||||||
|
describe 'initial state' do
|
||||||
|
it 'starts in draft state' do
|
||||||
|
expect(cohort.draft?).to be true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#activate event' do
|
||||||
|
it 'transitions from draft to active' do
|
||||||
|
expect { cohort.activate! }
|
||||||
|
.to change(cohort, :status).from('draft').to('active')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'sets tp_signed_at timestamp' do
|
||||||
|
expect { cohort.activate! }
|
||||||
|
.to change(cohort, :tp_signed_at).from(nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'cannot transition from completed to active' do
|
||||||
|
cohort.update!(status: 'completed')
|
||||||
|
expect { cohort.activate! }.to raise_error(AASM::InvalidTransition)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#complete event' do
|
||||||
|
before { cohort.update!(status: 'active') }
|
||||||
|
|
||||||
|
it 'transitions from active to completed' do
|
||||||
|
expect { cohort.complete! }
|
||||||
|
.to change(cohort, :status).from('active').to('completed')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'sets finalized_at timestamp' do
|
||||||
|
expect { cohort.complete! }
|
||||||
|
.to change(cohort, :finalized_at).from(nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'cannot transition from draft to completed' do
|
||||||
|
cohort.update!(status: 'draft')
|
||||||
|
expect { cohort.complete! }.to raise_error(AASM::InvalidTransition)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'state predicates' do
|
||||||
|
it 'provides draft? predicate' do
|
||||||
|
cohort.update!(status: 'draft')
|
||||||
|
expect(cohort.draft?).to be true
|
||||||
|
expect(cohort.active?).to be false
|
||||||
|
expect(cohort.completed?).to be false
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'provides active? predicate' do
|
||||||
|
cohort.update!(status: 'active')
|
||||||
|
expect(cohort.active?).to be true
|
||||||
|
expect(cohort.draft?).to be false
|
||||||
|
expect(cohort.completed?).to be false
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'provides completed? predicate' do
|
||||||
|
cohort.update!(status: 'completed')
|
||||||
|
expect(cohort.completed?).to be true
|
||||||
|
expect(cohort.draft?).to be false
|
||||||
|
expect(cohort.active?).to be false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#all_students_completed?' do
|
||||||
|
let(:cohort) { create(:cohort) }
|
||||||
|
|
||||||
|
context 'when no student enrollments exist' do
|
||||||
|
it 'returns false' do
|
||||||
|
expect(cohort.all_students_completed?).to be false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when all student enrollments are completed' do
|
||||||
|
before do
|
||||||
|
create(:cohort_enrollment, cohort: cohort, role: 'student', status: 'complete')
|
||||||
|
create(:cohort_enrollment, cohort: cohort, role: 'student', status: 'complete')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns true' do
|
||||||
|
expect(cohort.all_students_completed?).to be true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when some student enrollments are not completed' do
|
||||||
|
before do
|
||||||
|
create(:cohort_enrollment, cohort: cohort, role: 'student', status: 'complete')
|
||||||
|
create(:cohort_enrollment, cohort: cohort, role: 'student', status: 'waiting')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns false' do
|
||||||
|
expect(cohort.all_students_completed?).to be false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when sponsor enrollment exists' do
|
||||||
|
before do
|
||||||
|
create(:cohort_enrollment, cohort: cohort, role: 'student', status: 'complete')
|
||||||
|
create(:cohort_enrollment, cohort: cohort, role: 'sponsor', status: 'waiting')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'only checks student enrollments' do
|
||||||
|
expect(cohort.all_students_completed?).to be true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#sponsor_access_ready?' do
|
||||||
|
let(:cohort) { create(:cohort, status: 'active', tp_signed_at: Time.current) }
|
||||||
|
|
||||||
|
context 'when all conditions are met' do
|
||||||
|
before do
|
||||||
|
create(:cohort_enrollment, cohort: cohort, role: 'student', status: 'complete')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns true' do
|
||||||
|
expect(cohort.sponsor_access_ready?).to be true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when cohort is not active' do
|
||||||
|
before do
|
||||||
|
cohort.update!(status: 'draft')
|
||||||
|
create(:cohort_enrollment, cohort: cohort, role: 'student', status: 'complete')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns false' do
|
||||||
|
expect(cohort.sponsor_access_ready?).to be false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when tp_signed_at is not set' do
|
||||||
|
before do
|
||||||
|
cohort.update!(tp_signed_at: nil)
|
||||||
|
create(:cohort_enrollment, cohort: cohort, role: 'student', status: 'complete')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns false' do
|
||||||
|
expect(cohort.sponsor_access_ready?).to be false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when not all students are completed' do
|
||||||
|
before do
|
||||||
|
create(:cohort_enrollment, cohort: cohort, role: 'student', status: 'waiting')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns false' do
|
||||||
|
expect(cohort.sponsor_access_ready?).to be false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#tp_can_sign?' do
|
||||||
|
it 'returns true when cohort is in draft state' do
|
||||||
|
cohort = create(:cohort, status: 'draft')
|
||||||
|
expect(cohort.tp_can_sign?).to be true
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns false when cohort is active' do
|
||||||
|
cohort = create(:cohort, status: 'active')
|
||||||
|
expect(cohort.tp_can_sign?).to be false
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns false when cohort is completed' do
|
||||||
|
cohort = create(:cohort, status: 'completed')
|
||||||
|
expect(cohort.tp_can_sign?).to be false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'soft delete functionality' do
|
||||||
|
let(:cohort) { create(:cohort) }
|
||||||
|
|
||||||
|
it 'soft deletes the record' do
|
||||||
|
expect { cohort.soft_delete }
|
||||||
|
.to change { cohort.reload.deleted_at }.from(nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'excludes soft-deleted records from default scope' do
|
||||||
|
cohort.soft_delete
|
||||||
|
expect(Cohort.all).not_to include(cohort)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'restores soft-deleted records' do
|
||||||
|
cohort.soft_delete
|
||||||
|
expect { cohort.restore }
|
||||||
|
.to change { cohort.reload.deleted_at }.to(nil)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@ -0,0 +1,105 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe SoftDeletable, type: :model do
|
||||||
|
# Create a test model that includes the concern
|
||||||
|
let(:test_class) do
|
||||||
|
Class.new(ApplicationRecord) do
|
||||||
|
self.table_name = 'institutions'
|
||||||
|
include SoftDeletable
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
let(:record) { test_class.create!(name: 'Test', email: 'test@example.com') }
|
||||||
|
|
||||||
|
describe 'scopes' do
|
||||||
|
let!(:active_record) { test_class.create!(name: 'Active', email: 'active@example.com') }
|
||||||
|
let!(:deleted_record) do
|
||||||
|
test_class.create!(name: 'Deleted', email: 'deleted@example.com', deleted_at: Time.current)
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '.active' do
|
||||||
|
it 'returns only non-deleted records' do
|
||||||
|
expect(test_class.active).to include(active_record)
|
||||||
|
expect(test_class.active).not_to include(deleted_record)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '.archived' do
|
||||||
|
it 'returns only deleted records' do
|
||||||
|
expect(test_class.archived).to include(deleted_record)
|
||||||
|
expect(test_class.archived).not_to include(active_record)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '.with_archived' do
|
||||||
|
it 'returns all records including deleted' do
|
||||||
|
expect(test_class.with_archived).to include(active_record, deleted_record)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'default scope' do
|
||||||
|
it 'excludes soft-deleted records by default' do
|
||||||
|
expect(test_class.all).to include(active_record)
|
||||||
|
expect(test_class.all).not_to include(deleted_record)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#soft_delete' do
|
||||||
|
it 'sets deleted_at timestamp' do
|
||||||
|
expect { record.soft_delete }
|
||||||
|
.to change { record.reload.deleted_at }.from(nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns true on success' do
|
||||||
|
expect(record.soft_delete).to be true
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'excludes record from default scope after deletion' do
|
||||||
|
record.soft_delete
|
||||||
|
expect(test_class.all).not_to include(record)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#restore' do
|
||||||
|
before { record.update!(deleted_at: Time.current) }
|
||||||
|
|
||||||
|
it 'clears deleted_at timestamp' do
|
||||||
|
expect { record.restore }
|
||||||
|
.to change { record.reload.deleted_at }.to(nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns true on success' do
|
||||||
|
expect(record.restore).to be true
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'includes record in default scope after restoration' do
|
||||||
|
record.restore
|
||||||
|
expect(test_class.all).to include(record)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#deleted?' do
|
||||||
|
it 'returns false when deleted_at is nil' do
|
||||||
|
expect(record.deleted?).to be false
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns true when deleted_at is present' do
|
||||||
|
record.update!(deleted_at: Time.current)
|
||||||
|
expect(record.deleted?).to be true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#active?' do
|
||||||
|
it 'returns true when deleted_at is nil' do
|
||||||
|
expect(record.active?).to be true
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns false when deleted_at is present' do
|
||||||
|
record.update!(deleted_at: Time.current)
|
||||||
|
expect(record.active?).to be false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@ -0,0 +1,114 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe FeatureFlag, type: :model do
|
||||||
|
describe 'validations' do
|
||||||
|
subject { build(:feature_flag) }
|
||||||
|
|
||||||
|
it { should validate_presence_of(:name) }
|
||||||
|
it { should validate_uniqueness_of(:name) }
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '.enabled?' do
|
||||||
|
context 'when feature flag exists and is enabled' do
|
||||||
|
before { create(:feature_flag, name: 'test_feature', enabled: true) }
|
||||||
|
|
||||||
|
it 'returns true' do
|
||||||
|
expect(FeatureFlag.enabled?('test_feature')).to be true
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'accepts symbol as parameter' do
|
||||||
|
expect(FeatureFlag.enabled?(:test_feature)).to be true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when feature flag exists and is disabled' do
|
||||||
|
before { create(:feature_flag, name: 'test_feature', enabled: false) }
|
||||||
|
|
||||||
|
it 'returns false' do
|
||||||
|
expect(FeatureFlag.enabled?('test_feature')).to be false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when feature flag does not exist' do
|
||||||
|
it 'returns false' do
|
||||||
|
expect(FeatureFlag.enabled?('nonexistent_feature')).to be false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '.enable!' do
|
||||||
|
context 'when feature flag exists' do
|
||||||
|
let!(:flag) { create(:feature_flag, name: 'test_feature', enabled: false) }
|
||||||
|
|
||||||
|
it 'enables the feature flag' do
|
||||||
|
expect { FeatureFlag.enable!('test_feature') }
|
||||||
|
.to change { flag.reload.enabled }.from(false).to(true)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns true' do
|
||||||
|
expect(FeatureFlag.enable!('test_feature')).to be true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when feature flag does not exist' do
|
||||||
|
it 'creates and enables the feature flag' do
|
||||||
|
expect { FeatureFlag.enable!('new_feature') }
|
||||||
|
.to change(FeatureFlag, :count).by(1)
|
||||||
|
|
||||||
|
expect(FeatureFlag.find_by(name: 'new_feature').enabled).to be true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'accepts symbol as parameter' do
|
||||||
|
FeatureFlag.enable!(:symbol_feature)
|
||||||
|
expect(FeatureFlag.enabled?(:symbol_feature)).to be true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '.disable!' do
|
||||||
|
context 'when feature flag exists' do
|
||||||
|
let!(:flag) { create(:feature_flag, name: 'test_feature', enabled: true) }
|
||||||
|
|
||||||
|
it 'disables the feature flag' do
|
||||||
|
expect { FeatureFlag.disable!('test_feature') }
|
||||||
|
.to change { flag.reload.enabled }.from(true).to(false)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns true' do
|
||||||
|
expect(FeatureFlag.disable!('test_feature')).to be true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when feature flag does not exist' do
|
||||||
|
it 'creates and disables the feature flag' do
|
||||||
|
expect { FeatureFlag.disable!('new_feature') }
|
||||||
|
.to change(FeatureFlag, :count).by(1)
|
||||||
|
|
||||||
|
expect(FeatureFlag.find_by(name: 'new_feature').enabled).to be false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'accepts symbol as parameter' do
|
||||||
|
FeatureFlag.disable!(:symbol_feature)
|
||||||
|
expect(FeatureFlag.enabled?(:symbol_feature)).to be false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'default feature flags' do
|
||||||
|
it 'includes flodoc_cohorts flag' do
|
||||||
|
# This assumes migration has been run
|
||||||
|
flag = FeatureFlag.find_by(name: 'flodoc_cohorts')
|
||||||
|
expect(flag).to be_present
|
||||||
|
expect(flag.enabled).to be true
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'includes flodoc_portals flag' do
|
||||||
|
# This assumes migration has been run
|
||||||
|
flag = FeatureFlag.find_by(name: 'flodoc_portals')
|
||||||
|
expect(flag).to be_present
|
||||||
|
expect(flag.enabled).to be true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@ -0,0 +1,158 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe Institution, type: :model do
|
||||||
|
describe 'concerns' do
|
||||||
|
it 'includes SoftDeletable' do
|
||||||
|
expect(Institution.ancestors).to include(SoftDeletable)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'associations' do
|
||||||
|
it { should have_many(:cohorts).dependent(:destroy) }
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'validations' do
|
||||||
|
it { should validate_presence_of(:name) }
|
||||||
|
it { should validate_presence_of(:email) }
|
||||||
|
it { should validate_length_of(:name).is_at_least(2).is_at_most(255) }
|
||||||
|
|
||||||
|
it 'validates email format' do
|
||||||
|
institution = build(:institution, email: 'invalid-email')
|
||||||
|
expect(institution).not_to be_valid
|
||||||
|
expect(institution.errors[:email]).to be_present
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'accepts valid email format' do
|
||||||
|
institution = build(:institution, email: 'valid@example.com')
|
||||||
|
expect(institution).to be_valid
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'strip_attributes' do
|
||||||
|
it 'strips whitespace from name' do
|
||||||
|
institution = create(:institution, name: ' Test Institution ')
|
||||||
|
expect(institution.name).to eq('Test Institution')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'strips whitespace from email' do
|
||||||
|
institution = create(:institution, email: ' test@example.com ')
|
||||||
|
expect(institution.email).to eq('test@example.com')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'strips whitespace from contact_person' do
|
||||||
|
institution = create(:institution, contact_person: ' John Doe ')
|
||||||
|
expect(institution.contact_person).to eq('John Doe')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'strips whitespace from phone' do
|
||||||
|
institution = create(:institution, phone: ' +27123456789 ')
|
||||||
|
expect(institution.phone).to eq('+27123456789')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'scopes' do
|
||||||
|
let!(:active_institution) { create(:institution) }
|
||||||
|
let!(:deleted_institution) { create(:institution, deleted_at: Time.current) }
|
||||||
|
|
||||||
|
describe '.active' do
|
||||||
|
it 'returns only active institutions' do
|
||||||
|
expect(Institution.active).to include(active_institution)
|
||||||
|
expect(Institution.active).not_to include(deleted_institution)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '.archived' do
|
||||||
|
it 'returns only soft-deleted institutions' do
|
||||||
|
expect(Institution.archived).to include(deleted_institution)
|
||||||
|
expect(Institution.archived).not_to include(active_institution)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '.current' do
|
||||||
|
context 'when institutions exist' do
|
||||||
|
let!(:first_institution) { create(:institution, name: 'First Institution') }
|
||||||
|
let!(:second_institution) { create(:institution, name: 'Second Institution') }
|
||||||
|
|
||||||
|
it 'returns the first institution' do
|
||||||
|
expect(Institution.current).to eq(first_institution)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when no institutions exist' do
|
||||||
|
it 'returns nil' do
|
||||||
|
expect(Institution.current).to be_nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#settings_with_defaults' do
|
||||||
|
let(:institution) { create(:institution, settings: custom_settings) }
|
||||||
|
|
||||||
|
context 'with empty settings' do
|
||||||
|
let(:custom_settings) { {} }
|
||||||
|
|
||||||
|
it 'returns default settings' do
|
||||||
|
settings = institution.settings_with_defaults
|
||||||
|
expect(settings[:allow_student_enrollment]).to be true
|
||||||
|
expect(settings[:require_verification]).to be true
|
||||||
|
expect(settings[:auto_finalize]).to be false
|
||||||
|
expect(settings[:email_notifications]).to be true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with custom settings' do
|
||||||
|
let(:custom_settings) do
|
||||||
|
{
|
||||||
|
'allow_student_enrollment' => false,
|
||||||
|
'custom_setting' => 'custom_value'
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'merges custom settings with defaults' do
|
||||||
|
settings = institution.settings_with_defaults
|
||||||
|
expect(settings[:allow_student_enrollment]).to be false
|
||||||
|
expect(settings[:require_verification]).to be true
|
||||||
|
expect(settings[:custom_setting]).to eq('custom_value')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns HashWithIndifferentAccess' do
|
||||||
|
expect(institution.settings_with_defaults).to be_a(ActiveSupport::HashWithIndifferentAccess)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'soft delete functionality' do
|
||||||
|
let(:institution) { create(:institution) }
|
||||||
|
|
||||||
|
it 'soft deletes the record' do
|
||||||
|
expect { institution.soft_delete }
|
||||||
|
.to change { institution.reload.deleted_at }.from(nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'excludes soft-deleted records from default scope' do
|
||||||
|
institution.soft_delete
|
||||||
|
expect(Institution.all).not_to include(institution)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'restores soft-deleted records' do
|
||||||
|
institution.soft_delete
|
||||||
|
expect { institution.restore }
|
||||||
|
.to change { institution.reload.deleted_at }.to(nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'checks if record is deleted' do
|
||||||
|
expect(institution.deleted?).to be false
|
||||||
|
institution.soft_delete
|
||||||
|
expect(institution.deleted?).to be true
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'checks if record is active' do
|
||||||
|
expect(institution.active?).to be true
|
||||||
|
institution.soft_delete
|
||||||
|
expect(institution.active?).to be false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
Loading…
Reference in new issue