Commit 28f5054e authored by Peter Leitzen's avatar Peter Leitzen Committed by Stan Hu

Add table and model for `zoom_meetings`

Add Active Record backed class `ZoomMeeting`s to persist and validate
zoom links associated to an issue. We also associate meetings to a
project (via project_id) to avoid table joins. `ZoomMeeting`s can have
an issue_status of `added` or `removed` which will be used to show or
hide the zoom link on an issue via quick actions.
parent ff5f1c4c
# frozen_string_literal: true
class ZoomMeeting < ApplicationRecord
belongs_to :project, optional: false
belongs_to :issue, optional: false
validates :url, presence: true, length: { maximum: 255 }, zoom_url: true
validates :issue, same_project_association: true
enum issue_status: {
added: 1,
removed: 2
}
scope :added_to_issue, -> { where(issue_status: :added) }
scope :removed_from_issue, -> { where(issue_status: :removed) }
end
# frozen_string_literal: true
# SameProjectAssociationValidator
#
# Custom validator to validate that the same project associated with
# the record is also associated with the value
#
# Example:
# class ZoomMeeting < ApplicationRecord
# belongs_to :project, optional: false
# belongs_to :issue, optional: false
# validates :issue, same_project_association: true
# end
class SameProjectAssociationValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
return if record.project == value&.project
record.errors[attribute] << 'must associate the same project'
end
end
# frozen_string_literal: true
# ZoomUrlValidator
#
# Custom validator for zoom urls
#
class ZoomUrlValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
return if Gitlab::ZoomLinkExtractor.new(value).links.size == 1
record.errors.add(:url, 'must contain one valid Zoom URL')
end
end
# frozen_string_literal: true
class CreateZoomMeetings < ActiveRecord::Migration[5.2]
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
ZOOM_MEETING_STATUS_ADDED = 1
def change
create_table :zoom_meetings do |t|
t.references :project, foreign_key: { on_delete: :cascade },
null: false
t.references :issue, foreign_key: { on_delete: :cascade },
null: false
t.timestamps_with_timezone null: false
t.integer :issue_status, limit: 2, default: 1, null: false
t.string :url, limit: 255
t.index [:issue_id, :issue_status], unique: true,
where: "issue_status = #{ZOOM_MEETING_STATUS_ADDED}"
end
end
end
......@@ -3992,6 +3992,18 @@ ActiveRecord::Schema.define(version: 2019_10_16_220135) do
t.index ["type"], name: "index_web_hooks_on_type"
end
create_table "zoom_meetings", force: :cascade do |t|
t.bigint "project_id", null: false
t.bigint "issue_id", null: false
t.datetime_with_timezone "created_at", null: false
t.datetime_with_timezone "updated_at", null: false
t.integer "issue_status", limit: 2, default: 1, null: false
t.string "url", limit: 255
t.index ["issue_id", "issue_status"], name: "index_zoom_meetings_on_issue_id_and_issue_status", unique: true, where: "(issue_status = 1)"
t.index ["issue_id"], name: "index_zoom_meetings_on_issue_id"
t.index ["project_id"], name: "index_zoom_meetings_on_project_id"
end
add_foreign_key "alerts_service_data", "services", on_delete: :cascade
add_foreign_key "allowed_email_domains", "namespaces", column: "group_id", on_delete: :cascade
add_foreign_key "analytics_cycle_analytics_group_stages", "labels", column: "end_event_label_id", on_delete: :cascade
......@@ -4406,4 +4418,6 @@ ActiveRecord::Schema.define(version: 2019_10_16_220135) do
add_foreign_key "vulnerability_scanners", "projects", on_delete: :cascade
add_foreign_key "web_hook_logs", "web_hooks", on_delete: :cascade
add_foreign_key "web_hooks", "projects", name: "fk_0c8ca6d9d1", on_delete: :cascade
add_foreign_key "zoom_meetings", "issues", on_delete: :cascade
add_foreign_key "zoom_meetings", "projects", on_delete: :cascade
end
# frozen_string_literal: true
FactoryBot.define do
factory :zoom_meeting do
project { issue.project }
issue
url { 'https://zoom.us/j/123456789' }
issue_status { :added }
trait :added_to_issue do
issue_status { :added }
end
trait :removed_from_issue do
issue_status { :removed }
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe ZoomMeeting do
let(:project) { build(:project) }
describe 'Factory' do
subject { build(:zoom_meeting) }
it { is_expected.to be_valid }
end
describe 'Associations' do
it { is_expected.to belong_to(:project).required }
it { is_expected.to belong_to(:issue).required }
end
describe 'scopes' do
let(:issue) { create(:issue, project: project) }
let!(:added_meeting) { create(:zoom_meeting, :added_to_issue, issue: issue) }
let!(:removed_meeting) { create(:zoom_meeting, :removed_from_issue, issue: issue) }
describe '.added_to_issue' do
it 'gets only added meetings' do
meetings_added = described_class.added_to_issue.pluck(:id)
expect(meetings_added).to include(added_meeting.id)
expect(meetings_added).not_to include(removed_meeting.id)
end
end
describe '.removed_from_issue' do
it 'gets only removed meetings' do
meetings_removed = described_class.removed_from_issue.pluck(:id)
expect(meetings_removed).to include(removed_meeting.id)
expect(meetings_removed).not_to include(added_meeting.id)
end
end
end
describe 'Validations' do
describe 'url' do
it { is_expected.to validate_presence_of(:url) }
it { is_expected.to validate_length_of(:url).is_at_most(255) }
shared_examples 'invalid Zoom URL' do
it do
expect(subject).to be_invalid
expect(subject.errors[:url])
.to contain_exactly('must contain one valid Zoom URL')
end
end
context 'with non-Zoom URL' do
before do
subject.url = %{https://non-zoom.url}
end
include_examples 'invalid Zoom URL'
end
context 'with multiple Zoom-URLs' do
before do
subject.url = %{https://zoom.us/j/123 https://zoom.us/j/456}
end
include_examples 'invalid Zoom URL'
end
end
describe 'issue association' do
let(:issue) { build(:issue, project: project) }
subject { build(:zoom_meeting, project: project, issue: issue) }
context 'for the same project' do
it { is_expected.to be_valid }
end
context 'for a different project' do
let(:issue) { build(:issue) }
it do
expect(subject).to be_invalid
expect(subject.errors[:issue])
.to contain_exactly('must associate the same project')
end
end
end
end
describe 'limit number of meetings per issue' do
shared_examples 'can add meetings' do
it 'can add new Zoom meetings' do
create(:zoom_meeting, :added_to_issue, issue: issue)
end
end
shared_examples 'can remove meetings' do
it 'can remove Zoom meetings' do
create(:zoom_meeting, :removed_from_issue, issue: issue)
end
end
shared_examples 'cannot add meetings' do
it 'fails to add a new meeting' do
expect do
create(:zoom_meeting, :added_to_issue, issue: issue)
end.to raise_error ActiveRecord::RecordNotUnique
end
end
let(:issue) { create(:issue, project: project) }
context 'without meetings' do
it_behaves_like 'can add meetings'
end
context 'when no other meeting is added' do
before do
create(:zoom_meeting, :removed_from_issue, issue: issue)
end
it_behaves_like 'can add meetings'
end
context 'when meeting is added' do
before do
create(:zoom_meeting, :added_to_issue, issue: issue)
end
it_behaves_like 'cannot add meetings'
end
context 'when meeting is added to another issue' do
let(:another_issue) { create(:issue, project: project) }
before do
create(:zoom_meeting, :added_to_issue, issue: another_issue)
end
it_behaves_like 'can add meetings'
end
context 'when second meeting is removed' do
before do
create(:zoom_meeting, :removed_from_issue, issue: issue)
end
it_behaves_like 'can remove meetings'
end
end
end
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment