canvas_sync 0.27.5 → 0.27.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. checksums.yaml +4 -4
  2. data/lib/canvas_sync/concerns/ability_helper.rb +1 -1
  3. data/lib/canvas_sync/concerns/deleted_scoping.rb +2 -4
  4. data/lib/canvas_sync/concerns/user_via_pseudonym.rb +5 -1
  5. data/lib/canvas_sync/config.rb +5 -3
  6. data/lib/canvas_sync/engine.rb +2 -2
  7. data/lib/canvas_sync/generators/templates/models/assignment_override.rb +1 -1
  8. data/lib/canvas_sync/generators/templates/models/context_module.rb +2 -2
  9. data/lib/canvas_sync/generators/templates/models/context_module_item.rb +1 -1
  10. data/lib/canvas_sync/generators/templates/models/course_progress.rb +1 -1
  11. data/lib/canvas_sync/generators/templates/models/learning_outcome.rb +1 -1
  12. data/lib/canvas_sync/generators/templates/models/learning_outcome_result.rb +1 -1
  13. data/lib/canvas_sync/importers/bulk_importer.rb +2 -2
  14. data/lib/canvas_sync/job.rb +5 -1
  15. data/lib/canvas_sync/jobs/report_sync_task.rb +1 -1
  16. data/lib/canvas_sync/jobs/sync_provisioning_report_job.rb +5 -1
  17. data/lib/canvas_sync/jobs/sync_submissions_job.rb +1 -1
  18. data/lib/canvas_sync/live_events/process_event_job.rb +5 -1
  19. data/lib/canvas_sync/reports_map.rb +14 -5
  20. data/lib/canvas_sync/version.rb +1 -1
  21. data/lib/canvas_sync.rb +2 -2
  22. data/spec/canvas_sync/canvas_sync_spec.rb +88 -0
  23. data/spec/canvas_sync/concerns/deleted_scoping_spec.rb +46 -0
  24. data/spec/canvas_sync/concerns/legacy_columns_spec.rb +59 -0
  25. data/spec/canvas_sync/engine_spec.rb +25 -0
  26. data/spec/canvas_sync/jobs/begin_sync_chain_job_spec.rb +245 -0
  27. data/spec/canvas_sync/jobs/job_spec.rb +58 -0
  28. data/spec/canvas_sync/jobs/term_batches_job_spec.rb +181 -0
  29. data/spec/canvas_sync/live_events/process_event_job_spec.rb +39 -0
  30. data/spec/canvas_sync/models/job_log_spec.rb +29 -0
  31. data/spec/spec_helper.rb +6 -0
  32. metadata +27 -35
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d6f24c18ebf86f750d70099ea3e005f7036a757b37585c6b90e8cb8118320a6d
4
- data.tar.gz: 1228fee5adaec59e4fbd29b464950d2a1a60e0f4d3d0fd2dfacdacb36ae800e8
3
+ metadata.gz: bc79a9e2241e08965571784e4078895a0be737b2075d9dc7068cee78fee90a39
4
+ data.tar.gz: 45a423897e00e97f90a785374ca10d967275874b61b1cb9a2afcfd69dccbb3bd
5
5
  SHA512:
6
- metadata.gz: dad20a38ebc24dfb459f6993ad9a24e02a40f5a6c68c4403df3bb90500b0676857e07fb93a582fcd5b28c0a78501d823143c3364e93fbb9a3d9f40134a9af2da
7
- data.tar.gz: fefc18626e5222c0402a399ca8caf3c131e9cebe0ce16df6233ce3cba9904839f0ffc7ccebe3deee2cd7695c2db33735957662935340695a63592385449a91ca
6
+ metadata.gz: 7389e3c5fdc2dcb65b2330b19d5850298dddcc8fcc1ed57873f79594ad5bae59c2754ab6b1f1ef8350e9d2cbe8442e2631ca4dd7d90eb64ef4be1e6909245d7a
7
+ data.tar.gz: 584dfdd57d8ff294560c62c584b46660f0177e3841a719bee167b13b879418c2553de007baad1b1866ad459234880219038d5037805da331b4a230ac5e2b3e07
@@ -77,7 +77,7 @@ module CanvasSync::Concerns
77
77
  end
78
78
 
79
79
  def canvas_roles
80
- @canvas_roles ||= Role.for_labels(panda_pal_session.canvas_role_labels, launch_account)
80
+ @canvas_roles ||= ::Role.for_labels(panda_pal_session.canvas_role_labels, launch_account)
81
81
  end
82
82
 
83
83
  def canvas_root_account_roles
@@ -11,10 +11,8 @@ module CanvasSync::Concerns
11
11
 
12
12
  included do
13
13
  default_scope do
14
- model = scope.try(:model) || scope
15
-
16
- if model.try(:column_names)&.include?(:workflow_state)
17
- scope.where.not(workflow_state: %w[deleted])
14
+ if column_names.include?("workflow_state")
15
+ where.not(workflow_state: %w[deleted])
18
16
  else
19
17
  all
20
18
  end
@@ -6,7 +6,11 @@ module CanvasSync::Concerns
6
6
  extend ActiveSupport::Concern
7
7
 
8
8
  def where!(*args)
9
- return super unless self.respond_to?(:users_via_pseudonym)
9
+ # Check the model class rather than the relation. ActiveRecord::Relation#respond_to?
10
+ # builds the relation's arel as a side effect (via respond_to_missing?), which marks
11
+ # the relation immutable and makes the super call below raise ImmutableRelation on
12
+ # Rails < 6 (e.g. during schema load).
13
+ return super unless klass.respond_to?(:users_via_pseudonym)
10
14
 
11
15
  super.tap do |q|
12
16
  args.select { |a| a.is_a?(Hash) }.each do |hash|
@@ -1,8 +1,10 @@
1
1
  module CanvasSync
2
2
  class Config
3
- include ActiveSupport::Configurable
3
+ attr_accessor :classes_to_only_log_errors_on, :redis_key_prefix
4
4
 
5
- config_accessor(:classes_to_only_log_errors_on) { ["CanvasSync::Jobs::ReportChecker", "CanvasSync::Jobs::ReportSyncTask::CheckerJob"] }
6
- config_accessor(:redis_key_prefix) { "cs" }
5
+ def initialize
6
+ @classes_to_only_log_errors_on = ["CanvasSync::Jobs::ReportChecker", "CanvasSync::Jobs::ReportSyncTask::CheckerJob"]
7
+ @redis_key_prefix = "cs"
8
+ end
7
9
  end
8
10
  end
@@ -41,7 +41,7 @@ module CanvasSync
41
41
  ensure
42
42
  ChronicDuration.raise_exceptions = origExc
43
43
  end
44
- }
44
+ },
45
45
  }
46
46
 
47
47
  initializer 'canvas_sync.global_methods' do
@@ -74,7 +74,7 @@ module CanvasSync
74
74
  properties: {
75
75
  job_log_retention: { **RETENTION_TYPE },
76
76
  sync_batch_retention: { **RETENTION_TYPE },
77
- }
77
+ },
78
78
  })
79
79
  end
80
80
 
@@ -23,6 +23,6 @@ class AssignmentOverride < ApplicationRecord
23
23
  student_ids: ->(r) { JSON.parse(r[:student_ids]) if r[:student_ids].present? },
24
24
  canvas_group_id: :group_id,
25
25
  canvas_section_id: :course_section_id,
26
- canvas_course_id: -> (r) { r.dig(:assignment, :course_id) }
26
+ canvas_course_id: -> (r) { r.dig(:assignment, :course_id) },
27
27
  }, -> (api) { api.assignment_override(canvas_assignment_id, canvas_id) })
28
28
  end
@@ -35,7 +35,7 @@ class ContextModule < ApplicationRecord
35
35
  unlock_at: :unlock_at,
36
36
  completion_requirements: :completion_requirements,
37
37
  prerequisites: :prerequisites,
38
- requirement_count: :requirement_count
38
+ requirement_count: :requirement_count,
39
39
  }, lambda { |api|
40
40
  course_modules = api.course_modules(canvas_context_id, { include: ['items'] }) # avoids having 2 API calls for modules as we reuse this data
41
41
  context_module = course_modules&.detect { |course_module| course_module['id'] == canvas_id }
@@ -53,7 +53,7 @@ class ContextModule < ApplicationRecord
53
53
  {
54
54
  id: course_module['id'],
55
55
  name: course_module['name'],
56
- type: 'context_module'
56
+ type: 'context_module',
57
57
  }.with_indifferent_access
58
58
  end.compact
59
59
 
@@ -18,7 +18,7 @@ class ContextModuleItem < ApplicationRecord
18
18
  position: :position,
19
19
  canvas_content_type: :type,
20
20
  canvas_content_id: :content_id,
21
- canvas_assignment_id: :assignment_id
21
+ canvas_assignment_id: :assignment_id,
22
22
  }, lambda { |api|
23
23
  module_item = api.module_item(context_module.context.canvas_id, context_module.canvas_id, canvas_id)
24
24
  module_item['assignment_id'] = api.assignment(context_module.context.canvas_id, module_item['content_id'])['id'] if module_item['type'] == 'Assignment'
@@ -18,6 +18,6 @@ class CourseProgress < ApplicationRecord
18
18
  requirement_count: :requirement_count,
19
19
  requirement_completed_count: :requirement_completed_count,
20
20
  # provisioning report has completion_date instead of completed_at in the API
21
- completion_date: :completed_at
21
+ completion_date: :completed_at,
22
22
  }, -> (api) { api.course_progress(canvas_course_id, canvas_user_id) })
23
23
  end
@@ -19,6 +19,6 @@ class LearningOutcome < ApplicationRecord
19
19
  vendor_guid: :vendor_guid,
20
20
  calculation_method: :calculation_method,
21
21
  calculation_int: :calculation_int,
22
- description: :description
22
+ description: :description,
23
23
  }, ->(api) { api.get("/api/v1/outcomes/#{canvas_id}") })
24
24
  end
@@ -41,7 +41,7 @@ class LearningOutcomeResult < ActiveRecord::Base
41
41
  canvas_user_id: :links&.[]("user"),
42
42
  score: :score,
43
43
  assessed_at: :submitted_or_assessed_at,
44
- percent: :percent
44
+ percent: :percent,
45
45
  }, ->(api) {
46
46
  api.get("/api/v1/courses/#{self.canvas_context_id}/#{self.canvas_id}")&.find { |item| item[:id] == self.canvas_id }
47
47
  })
@@ -22,7 +22,7 @@ module CanvasSync
22
22
 
23
23
  # NEW: (report_file_path, klass, mapping, import_args: {}, &blk)
24
24
  # LEGACY: (report_file_path, columns, klass, conflict_target, import_args: {}, &blk)
25
- def self.import(report_file_path, *args, import_args: {}, &blk) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/LineLength
25
+ def self.import(report_file_path, *args, import_args: {}, &blk) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
26
26
  if args.length == 2
27
27
  klass = args[0]
28
28
  mapping = args[1]
@@ -129,7 +129,7 @@ module CanvasSync
129
129
 
130
130
  update_conditions = {
131
131
  condition: condition_sql(klass, columns, import_args[:sync_start_time]),
132
- columns: columns
132
+ columns: columns,
133
133
  }
134
134
  update_conditions[:conflict_target] = conflict_target if conflict_target.present?
135
135
 
@@ -32,7 +32,11 @@ module CanvasSync
32
32
  rescue => e2
33
33
  @job_log.backtrace += "\n\nError Occurred while handling an Error: #{e2.class}: #{e2.message}"
34
34
  @job_log.backtrace += "\n" + e2.backtrace.join('\n')
35
- Raven.captureException(e2) if defined? Raven
35
+ if Rails.respond_to?(:error)
36
+ Rails.error.report(e2, handled: true)
37
+ elsif defined?(Sentry)
38
+ Sentry.capture_exception(e2)
39
+ end
36
40
  end
37
41
  end
38
42
  raise e
@@ -216,7 +216,7 @@ module CanvasSync
216
216
 
217
217
  case report_status["status"].downcase
218
218
  when "complete"
219
- report_task.class::ProcessJob.perform_later(report_status["attachment"]["url"])
219
+ ProcessJob.perform_later(report_status["attachment"]["url"])
220
220
  when "error", "deleted"
221
221
  max_tries = options[:report_max_tries] || batch_context[:report_max_tries] || report_task.max_tries || MAX_TRIES
222
222
  report_attempt = batch_context[:report_attempt]
@@ -20,7 +20,11 @@ module CanvasSync
20
20
 
21
21
  # Make sure the report also checks last_activity_at when checking updated_at
22
22
  if options[:models].include?("enrollments")
23
- if (%w[last_activity_at total_activity_time] & CanvasSync::Concerns::SyncMapping.mapping_for(Enrollment)[:columns].keys).present? && merged_params.dig(:parameters, :include_last_activity) == nil
23
+ enrollment_columns = CanvasSync::Concerns::SyncMapping.mapping_for(Enrollment)[:columns].keys
24
+ tracks_last_activity = (%w[last_activity_at total_activity_time] & enrollment_columns).present?
25
+ include_last_activity_set = !merged_params.dig(:parameters, :include_last_activity).nil?
26
+
27
+ if tracks_last_activity && !include_last_activity_set
24
28
  merged_params[:parameters][:include_last_activity] = true
25
29
  end
26
30
  end
@@ -7,7 +7,7 @@ module CanvasSync
7
7
  merge_report_params(options, {
8
8
  include_all: options[:include_all],
9
9
  enrollment_batch_size: options[:enrollment_batch_size],
10
- include_all_except: options[:include_all_except]
10
+ include_all_except: options[:include_all_except],
11
11
  })
12
12
  end
13
13
  end
@@ -19,7 +19,11 @@ module CanvasSync::LiveEvents
19
19
  rescue => e
20
20
  throw e if Rails.env.test?
21
21
  Rails.logger.error("LiveEvent Handler raised Error: #{e}")
22
- Raven.captureException(e) if defined? Raven
22
+ if Rails.respond_to?(:error)
23
+ Rails.error.report(e, handled: true)
24
+ elsif defined?(Sentry)
25
+ Sentry.capture_exception(e)
26
+ end
23
27
  end
24
28
  end
25
29
  end
@@ -95,8 +95,17 @@ module CanvasSync
95
95
  req
96
96
  end
97
97
 
98
+ ANSI_CODES = { red: 31, green: 32, cyan: 36, italic: 3 }.freeze
99
+ private_constant :ANSI_CODES
100
+
101
+ def self.ansi(text, style)
102
+ return text unless $stdout.tty?
103
+
104
+ "\e[#{ANSI_CODES.fetch(style)}m#{text}\e[0m"
105
+ end
106
+ private_class_method :ansi
107
+
98
108
  def self.pretty_print_required_reports(client = nil)
99
- require 'colorized_string'
100
109
  client ||= canvas_sync_client rescue nil
101
110
 
102
111
  req_reports = required_reports
@@ -107,13 +116,13 @@ module CanvasSync
107
116
  puts "[CanvasSync] Required Custom Reports:"
108
117
  req_reports.each do |report|
109
118
  color = enabled_reports.nil? ? :cyan : (enabled_reports.include?(report) ? :green : :red)
110
- puts "- #{ColorizedString[report].colorize(color)}"
119
+ puts "- #{ansi(report, color)}"
111
120
  if (meta = REPORTS[report]) && (meta[:human_name] || meta[:module_name])
112
121
  puts " #{meta[:human_name]} (#{meta[:module_name]})"
113
122
  end
114
123
  end
115
- puts "- #{ColorizedString["NB: Double check if you know this app requires non-CanvasSync provided models."].italic}"
116
- puts " #{ColorizedString["Non-standard reports must be registered with `CanvasSync.require_report!` from an initializer to be included in this list."].italic}"
124
+ puts "- #{ansi("NB: Double check if you know this app requires non-CanvasSync provided models.", :italic)}"
125
+ puts " #{ansi("Non-standard reports must be registered with `CanvasSync.require_report!` from an initializer to be included in this list.", :italic)}"
117
126
 
118
127
  puts ""
119
128
 
@@ -124,7 +133,7 @@ module CanvasSync
124
133
  base_url = client.config[:prefix] rescue "<CANVAS URL>/"
125
134
  plugins_url = "#{base_url}/plugins/account_reports".gsub(/\/+/, "/")
126
135
  plugins_url += "?#{bits.join("&")}" if bits.any?
127
- puts "Pre-filled link: " + ColorizedString[plugins_url].cyan
136
+ puts "Pre-filled link: " + ansi(plugins_url, :cyan)
128
137
  end
129
138
  end
130
139
  end
@@ -1,3 +1,3 @@
1
1
  module CanvasSync
2
- VERSION = "0.27.5".freeze
2
+ VERSION = "0.27.7".freeze
3
3
  end
data/lib/canvas_sync.rb CHANGED
@@ -151,7 +151,7 @@ module CanvasSync
151
151
  end
152
152
 
153
153
  model = scope.try(:model) || scope
154
- if model.try(:column_names)&.include?(:workflow_state)
154
+ if model.try(:column_names)&.include?("workflow_state")
155
155
  return scope.where.not(workflow_state: %w[deleted])
156
156
  end
157
157
  Rails.logger.warn("Could not filter Syncable Scope for model '#{scope.try(:model)&.name || scope.name}'")
@@ -174,7 +174,7 @@ module CanvasSync
174
174
  term_scoped_models: DEFAULT_TERM_SCOPE_MODELS,
175
175
  options: {},
176
176
  **kwargs
177
- ) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/LineLength
177
+ ) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
178
178
  return unless models.present?
179
179
  models.map! &:to_s
180
180
  term_scope = term_scope.to_s if term_scope
@@ -347,6 +347,94 @@ RSpec.describe CanvasSync do
347
347
  end
348
348
 
349
349
  describe ".sync_scope" do
350
+ it "uses .should_canvas_sync when the scope responds to it" do
351
+ filtered = double('filtered_scope')
352
+ scope = double('scope')
353
+ allow(scope).to receive(:should_canvas_sync).and_return(filtered)
350
354
 
355
+ expect(CanvasSync.sync_scope(scope)).to eq(filtered)
356
+ end
357
+
358
+ it "uses .active_for_canvas_sync when the scope responds to it" do
359
+ filtered = double('filtered_scope')
360
+ scope = double('scope')
361
+ allow(scope).to receive(:active_for_canvas_sync).and_return(filtered)
362
+
363
+ expect(CanvasSync.sync_scope(scope)).to eq(filtered)
364
+ end
365
+
366
+ it "uses .should_sync when the scope responds to it" do
367
+ filtered = double('filtered_scope')
368
+ scope = double('scope')
369
+ allow(scope).to receive(:should_sync).and_return(filtered)
370
+
371
+ expect(CanvasSync.sync_scope(scope)).to eq(filtered)
372
+ end
373
+
374
+ it "uses .active_for_sync when the scope responds to it" do
375
+ filtered = double('filtered_scope')
376
+ scope = double('scope')
377
+ allow(scope).to receive(:active_for_sync).and_return(filtered)
378
+
379
+ expect(CanvasSync.sync_scope(scope)).to eq(filtered)
380
+ end
381
+
382
+ context "the scope has none of the canvas_sync-specific methods, but does have .active" do
383
+ it "falls back to .active" do
384
+ # `Account` only defines `scope :active` (see the generator template); it does not
385
+ # define should_canvas_sync/active_for_canvas_sync/should_sync/active_for_sync.
386
+ expect(CanvasSync.sync_scope(Account).to_sql).to eq(Account.active.to_sql)
387
+ end
388
+ end
389
+
390
+ context "the scope has none of the recognized method conventions, but does have a workflow_state column" do
391
+ it "falls back to filtering out deleted rows" do
392
+ # `Group` has a workflow_state column but defines none of the sync-scope
393
+ # conventions (not even .active).
394
+ expect(CanvasSync.sync_scope(Group).to_sql).to eq(Group.where.not(workflow_state: %w[deleted]).to_sql)
395
+ end
396
+ end
397
+
398
+ context "the scope has none of the recognized method conventions and no workflow_state column" do
399
+ it "warns and returns the scope unchanged" do
400
+ # `CourseNickname` defines none of the sync-scope conventions and has no
401
+ # workflow_state column, so it falls through every branch.
402
+ expect(Rails.logger).to receive(:warn).with(/Could not filter Syncable Scope for model 'CourseNickname'/)
403
+
404
+ expect(CanvasSync.sync_scope(CourseNickname)).to eq(CourseNickname)
405
+ end
406
+ end
407
+ end
408
+
409
+ describe ".configure" do
410
+ after { CanvasSync.instance_variable_set(:@config, nil) }
411
+
412
+ it "yields the current CanvasSync::Config instance" do
413
+ yielded = nil
414
+ CanvasSync.configure { |c| yielded = c }
415
+
416
+ expect(yielded).to be_a(CanvasSync::Config)
417
+ expect(yielded).to equal(CanvasSync.config)
418
+ end
419
+
420
+ it "persists mutations made inside the block on .config" do
421
+ CanvasSync.configure { |c| c.redis_key_prefix = 'custom-prefix' }
422
+
423
+ expect(CanvasSync.config.redis_key_prefix).to eq('custom-prefix')
424
+ end
425
+ end
426
+
427
+ describe ".validate_live_events!" do
428
+ it "raises a helpful exception when an unsupported live event is given" do
429
+ expect {
430
+ CanvasSync.validate_live_events!(['course', 'bogus_event'])
431
+ }.to raise_error("Invalid live event(s) specified: bogus_event. Only #{CanvasSync::SUPPORTED_LIVE_EVENTS.join(', ')} are supported.")
432
+ end
433
+
434
+ it "does not raise when all live events are supported" do
435
+ expect {
436
+ CanvasSync.validate_live_events!(['course', 'enrollment', 'submission'])
437
+ }.to_not raise_error
438
+ end
351
439
  end
352
440
  end
@@ -0,0 +1,46 @@
1
+ require 'spec_helper'
2
+
3
+ RSpec.describe CanvasSync::Concerns::DeletedScoping do
4
+ with_model :ScopedWidget do
5
+ table do |t|
6
+ t.string :workflow_state
7
+ end
8
+ model do
9
+ include CanvasSync::Concerns::DeletedScoping
10
+ end
11
+ end
12
+
13
+ with_model :UnstatedWidget do
14
+ table do |t|
15
+ t.string :name
16
+ end
17
+ model do
18
+ include CanvasSync::Concerns::DeletedScoping
19
+ end
20
+ end
21
+
22
+ describe "default_scope" do
23
+ it "excludes deleted rows when the model has a workflow_state column" do
24
+ active = ScopedWidget.create!(workflow_state: 'active')
25
+ deleted = ScopedWidget.create!(workflow_state: 'deleted')
26
+
27
+ expect(ScopedWidget.all).to contain_exactly(active)
28
+ expect(ScopedWidget.with_deleted.all).to contain_exactly(active, deleted)
29
+ end
30
+
31
+ it "does not filter when the model has no workflow_state column" do
32
+ record = UnstatedWidget.create!(name: 'a widget')
33
+
34
+ expect(UnstatedWidget.all).to contain_exactly(record)
35
+ end
36
+ end
37
+
38
+ describe ".with_deleted" do
39
+ it "unscopes the workflow_state filter" do
40
+ active = ScopedWidget.create!(workflow_state: 'active')
41
+ deleted = ScopedWidget.create!(workflow_state: 'deleted')
42
+
43
+ expect(ScopedWidget.with_deleted).to contain_exactly(active, deleted)
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,59 @@
1
+ require 'spec_helper'
2
+
3
+ RSpec.describe CanvasSync::Concerns::LegacyColumns do
4
+ with_model :Widget do
5
+ table do |t|
6
+ t.string :canvas_id
7
+ end
8
+ model do
9
+ include CanvasSync::Concerns::LegacyColumns
10
+ end
11
+ end
12
+
13
+ with_model :Gadget do
14
+ table do |t|
15
+ t.string :canvas_gadget_id
16
+ end
17
+ model do
18
+ include CanvasSync::Concerns::LegacyColumns
19
+ end
20
+ end
21
+
22
+ context 'when the table only has a canvas_id column' do
23
+ it 'aliases canvas_<model>_id to canvas_id so both read/write the same value' do
24
+ widget = Widget.new
25
+ widget.canvas_id = '123'
26
+ expect(widget.canvas_widget_id).to eq('123')
27
+
28
+ widget.canvas_widget_id = '456'
29
+ expect(widget.canvas_id).to eq('456')
30
+ end
31
+ end
32
+
33
+ context 'when the table only has a canvas_<model>_id column' do
34
+ it 'aliases canvas_id to canvas_<model>_id so both read/write the same value' do
35
+ gadget = Gadget.new
36
+ gadget.canvas_id = '789'
37
+ expect(gadget.canvas_gadget_id).to eq('789')
38
+
39
+ gadget.canvas_gadget_id = '321'
40
+ expect(gadget.canvas_id).to eq('321')
41
+ end
42
+ end
43
+
44
+ context 'on subclass inheritance' do
45
+ it "re-applies the aliasing (keyed off of the subclass's own name) via the inherited hook" do
46
+ # Defined with `class ... < ...; end` (rather than `Class.new(Widget)`) so that Ruby
47
+ # assigns the constant name *before* invoking Widget.inherited - LegacyColumns needs
48
+ # `cls.name` to build the per-subclass alias, same as any real STI model definition.
49
+ class SuperWidget < Widget; end
50
+
51
+ super_widget = SuperWidget.new
52
+ super_widget.canvas_id = 'abc'
53
+ expect(super_widget.canvas_superwidget_id).to eq('abc')
54
+
55
+ super_widget.canvas_superwidget_id = 'xyz'
56
+ expect(super_widget.canvas_id).to eq('xyz')
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,25 @@
1
+ require 'spec_helper'
2
+
3
+ RSpec.describe "CanvasSync::Engine's canvas_sync.safe_yaml_classes initializer" do
4
+ # The initializer (lib/canvas_sync/engine.rb) whitelists Symbol and
5
+ # HashWithIndifferentAccess for AR's YAML-column permitted classes. Depending on the
6
+ # Rails version, that setting is exposed either as a class-level accessor on
7
+ # ActiveRecord::Base (older Rails) or as a global accessor on the ActiveRecord module
8
+ # (newer Rails). The initializer guards for both, and the app has already booted by
9
+ # the time specs run, so both possible locations should reflect the permitted classes.
10
+ it 'permits Symbol and HashWithIndifferentAccess wherever this Rails version exposes the setting' do
11
+ checked_any_location = false
12
+
13
+ if ActiveRecord::Base.respond_to?(:yaml_column_permitted_classes)
14
+ checked_any_location = true
15
+ expect(ActiveRecord::Base.yaml_column_permitted_classes).to include(Symbol, ActiveSupport::HashWithIndifferentAccess)
16
+ end
17
+
18
+ if ActiveRecord.respond_to?(:yaml_column_permitted_classes)
19
+ checked_any_location = true
20
+ expect(ActiveRecord.yaml_column_permitted_classes).to include(Symbol, ActiveSupport::HashWithIndifferentAccess)
21
+ end
22
+
23
+ expect(checked_any_location).to eq(true), "Expected this Rails version to expose yaml_column_permitted_classes somewhere"
24
+ end
25
+ end
@@ -0,0 +1,245 @@
1
+ require 'spec_helper'
2
+
3
+ RSpec.describe CanvasSync::Jobs::BeginSyncChainJob do
4
+ let(:job) { described_class.new }
5
+ let(:chain_definition) { { job: 'SomeJob' } }
6
+
7
+ # The real `JobBatches::Batch` persists to Redis and drives real job execution when
8
+ # `.jobs` is called. We stub it here so we can focus on BeginSyncChainJob's own
9
+ # responsibilities (computing updated_after, full-sync scheduling, refusing to run
10
+ # concurrently with another full sync, and creating/completing the SyncBatch record)
11
+ # without needing to actually run the downstream chain.
12
+ let(:batch_double) do
13
+ double('batch', bid: 'abc123').tap do |b|
14
+ allow(b).to receive(:description=)
15
+ allow(b).to receive(:on)
16
+ allow(b).to receive(:context=)
17
+ allow(b).to receive(:jobs).and_yield
18
+ end
19
+ end
20
+
21
+ before do
22
+ allow(CanvasSync::JobBatches::Batch).to receive(:new).and_return(batch_double)
23
+ allow(CanvasSync::JobBatches::SerialBatchJob).to receive(:perform_now)
24
+ end
25
+
26
+ describe '#perform' do
27
+ it 'creates a processing SyncBatch and stamps it with the new batch bid' do
28
+ expect {
29
+ job.perform(chain_definition, {})
30
+ }.to change { CanvasSync::SyncBatch.count }.by(1)
31
+
32
+ sync_batch = CanvasSync::SyncBatch.last
33
+ expect(sync_batch.status).to eq('processing')
34
+ expect(sync_batch.batch_genre).to eq('default')
35
+ expect(sync_batch.batch_bid).to eq('abc123')
36
+ expect(sync_batch.started_at).to be_present
37
+ end
38
+
39
+ it 'runs the chain definition inside a top-level SerialBatchJob, within the new Batch' do
40
+ expect(batch_double).to receive(:jobs).and_yield
41
+ expect(CanvasSync::JobBatches::SerialBatchJob).to receive(:perform_now)
42
+ .with(chain_definition, description: "Top Serial Batch")
43
+
44
+ job.perform(chain_definition, {})
45
+ end
46
+
47
+ it 'registers :complete and :success callbacks that finalize the SyncBatch' do
48
+ expect(batch_double).to receive(:on).with(:complete, "#{described_class}.batch_completed", sync_batch_id: anything)
49
+ expect(batch_double).to receive(:on).with(:success, "#{described_class}.batch_completed", sync_batch_id: anything)
50
+
51
+ job.perform(chain_definition, {})
52
+ end
53
+
54
+ context 'updated_after is not given' do
55
+ it 'derives updated_after from the day before the last completed SyncBatch of the same genre' do
56
+ # Note: `should_full_sync?` unconditionally forces a full sync (updated_after: nil)
57
+ # whenever there is no prior *full* sync at all, regardless of schedule. So we need a
58
+ # prior full-sync batch to exist, and we pin full_sync_every to a schedule that's
59
+ # clearly not due yet, so the derived delta value isn't overridden.
60
+ last_batch = CanvasSync::SyncBatch.create!(started_at: 10.days.ago, status: 'completed', full_sync: true, batch_genre: 'default')
61
+
62
+ job.perform(chain_definition, { full_sync_every: '99 days' })
63
+
64
+ expect(job.globals[:updated_after]).to eq((last_batch.started_at - 1.day).iso8601)
65
+ end
66
+
67
+ it 'defaults full_sync_every to "sunday/2" when not otherwise specified' do
68
+ job.perform(chain_definition, {})
69
+ expect(job.globals[:full_sync_every]).to eq('sunday/2')
70
+ end
71
+
72
+ it 'leaves updated_after nil (a full sync) when there is no prior completed SyncBatch' do
73
+ job.perform(chain_definition, {})
74
+
75
+ expect(job.globals[:updated_after]).to be_nil
76
+ expect(CanvasSync::SyncBatch.last.full_sync).to eq(true)
77
+ end
78
+
79
+ it 'only considers completed SyncBatches of the same genre' do
80
+ CanvasSync::SyncBatch.create!(started_at: 2.days.ago, status: 'completed', full_sync: true, batch_genre: 'other_genre')
81
+ recent_same_genre = CanvasSync::SyncBatch.create!(started_at: 10.days.ago, status: 'completed', full_sync: true, batch_genre: 'my_genre')
82
+
83
+ job.perform(chain_definition, { batch_genre: 'my_genre', full_sync_every: '99 days' })
84
+
85
+ expect(job.globals[:updated_after]).to eq((recent_same_genre.started_at - 1.day).iso8601)
86
+ end
87
+ end
88
+
89
+ context 'updated_after: false is explicitly given' do
90
+ it 'clears updated_after without computing a default full_sync_every' do
91
+ job.perform(chain_definition, { updated_after: false })
92
+
93
+ expect(job.globals[:updated_after]).to be_nil
94
+ expect(job.globals[:full_sync_every]).to be_nil
95
+ end
96
+ end
97
+
98
+ context 'a truthy updated_after value is given' do
99
+ it 'keeps the given value as-is' do
100
+ # A prior full sync must already exist, otherwise should_full_sync? unconditionally
101
+ # forces a full sync the first time around (see note above).
102
+ CanvasSync::SyncBatch.create!(started_at: 1.year.ago, status: 'completed', full_sync: true, batch_genre: 'default')
103
+
104
+ job.perform(chain_definition, { updated_after: '2024-01-01T00:00:00Z' })
105
+
106
+ expect(job.globals[:updated_after]).to eq('2024-01-01T00:00:00Z')
107
+ expect(CanvasSync::SyncBatch.last.full_sync).to eq(false)
108
+ end
109
+ end
110
+
111
+ context 'a same-genre full sync is already processing' do
112
+ it 'refuses to start a new sync, warns, and does not create a SyncBatch' do
113
+ CanvasSync::SyncBatch.create!(started_at: 1.hour.ago, status: 'processing', full_sync: true, batch_genre: 'default')
114
+
115
+ expect(Rails.logger).to receive(:warn).with(/Attempted to start a 'default' sync while a full-sync is still processing/)
116
+ expect(CanvasSync::JobBatches::Batch).to_not receive(:new)
117
+
118
+ expect {
119
+ job.perform(chain_definition, {})
120
+ }.to_not change { CanvasSync::SyncBatch.count }
121
+ end
122
+
123
+ it 'does not block a sync of a different genre' do
124
+ CanvasSync::SyncBatch.create!(started_at: 1.hour.ago, status: 'processing', full_sync: true, batch_genre: 'other_genre')
125
+
126
+ expect {
127
+ job.perform(chain_definition, { batch_genre: 'default' })
128
+ }.to change { CanvasSync::SyncBatch.count }.by(1)
129
+ end
130
+ end
131
+
132
+ context 'a same-genre full sync is processing but started over 12 hours ago' do
133
+ it 'treats it as stale and starts a new sync anyway' do
134
+ CanvasSync::SyncBatch.create!(started_at: 13.hours.ago, status: 'processing', full_sync: true, batch_genre: 'default')
135
+
136
+ expect {
137
+ job.perform(chain_definition, {})
138
+ }.to change { CanvasSync::SyncBatch.count }.by(1)
139
+ end
140
+ end
141
+
142
+ context 'should_full_sync? schedule is due' do
143
+ it 'forces a full sync (nil updated_after) even if an updated_after was given' do
144
+ allow_any_instance_of(described_class).to receive(:should_full_sync?).and_return(true)
145
+
146
+ job.perform(chain_definition, { updated_after: '2024-01-01T00:00:00Z' })
147
+
148
+ expect(job.globals[:updated_after]).to be_nil
149
+ expect(CanvasSync::SyncBatch.last.full_sync).to eq(true)
150
+ end
151
+ end
152
+ end
153
+
154
+ describe '#should_full_sync?' do
155
+ let(:job) { described_class.new }
156
+ before { allow(job).to receive(:genre).and_return('default') }
157
+
158
+ it 'returns true when there is no prior full sync of this genre' do
159
+ expect(job.should_full_sync?(nil)).to eq(true)
160
+ end
161
+
162
+ it 'returns false when a full sync exists but the option is not a String' do
163
+ CanvasSync::SyncBatch.create!(status: 'completed', full_sync: true, batch_genre: 'default', started_at: 1.month.ago)
164
+ expect(job.should_full_sync?(nil)).to eq(false)
165
+ end
166
+
167
+ it 'returns false for an unrecognized schedule format' do
168
+ CanvasSync::SyncBatch.create!(status: 'completed', full_sync: true, batch_genre: 'default', started_at: 1.month.ago)
169
+ expect(job.should_full_sync?('not-a-real-schedule')).to eq(false)
170
+ end
171
+
172
+ context 'a "<weekday>/<skip weeks>" schedule (e.g. "sunday/2")' do
173
+ it 'is true only on the matching weekday once the skip period has elapsed' do
174
+ Timecop.travel(DateTime.new(2024, 1, 7)) do # a Sunday
175
+ CanvasSync::SyncBatch.create!(status: 'completed', full_sync: true, batch_genre: 'default', started_at: 3.weeks.ago)
176
+ expect(job.should_full_sync?('sunday/2')).to eq(true)
177
+ end
178
+ end
179
+
180
+ it 'is false on the matching weekday if the skip period has not yet elapsed' do
181
+ Timecop.travel(DateTime.new(2024, 1, 7)) do # a Sunday
182
+ CanvasSync::SyncBatch.create!(status: 'completed', full_sync: true, batch_genre: 'default', started_at: 1.week.ago)
183
+ expect(job.should_full_sync?('sunday/2')).to eq(false)
184
+ end
185
+ end
186
+
187
+ it 'is false on a non-matching weekday even if the skip period has elapsed' do
188
+ Timecop.travel(DateTime.new(2024, 1, 8)) do # a Monday
189
+ CanvasSync::SyncBatch.create!(status: 'completed', full_sync: true, batch_genre: 'default', started_at: 3.weeks.ago)
190
+ expect(job.should_full_sync?('sunday/2')).to eq(false)
191
+ end
192
+ end
193
+ end
194
+
195
+ context 'a "<N>%" schedule' do
196
+ before do
197
+ CanvasSync::SyncBatch.create!(status: 'completed', full_sync: true, batch_genre: 'default', started_at: 1.month.ago)
198
+ end
199
+
200
+ it 'is true when the random roll lands under the given percentage' do
201
+ allow(job).to receive(:rand).with(100).and_return(10)
202
+ expect(job.should_full_sync?('50%')).to eq(true)
203
+ end
204
+
205
+ it 'is false when the random roll lands at or above the given percentage' do
206
+ allow(job).to receive(:rand).with(100).and_return(90)
207
+ expect(job.should_full_sync?('50%')).to eq(false)
208
+ end
209
+ end
210
+
211
+ context 'a "<N> days" schedule' do
212
+ it 'is true once at least N days have elapsed since the last full sync' do
213
+ CanvasSync::SyncBatch.create!(status: 'completed', full_sync: true, batch_genre: 'default', started_at: 5.days.ago)
214
+ expect(job.should_full_sync?('4 days')).to eq(true)
215
+ end
216
+
217
+ it 'is false when fewer than N days have elapsed' do
218
+ CanvasSync::SyncBatch.create!(status: 'completed', full_sync: true, batch_genre: 'default', started_at: 1.day.ago)
219
+ expect(job.should_full_sync?('4 days')).to eq(false)
220
+ end
221
+ end
222
+ end
223
+
224
+ describe '.batch_completed' do
225
+ it 'marks the SyncBatch as completed when the batch succeeded' do
226
+ sync_batch = CanvasSync::SyncBatch.create!(status: 'processing', started_at: Time.now)
227
+ status = double('status', success?: true)
228
+
229
+ described_class.batch_completed(status, { 'sync_batch_id' => sync_batch.id })
230
+
231
+ sync_batch.reload
232
+ expect(sync_batch.status).to eq('completed')
233
+ expect(sync_batch.completed_at).to be_present
234
+ end
235
+
236
+ it 'marks the SyncBatch as failed when the batch did not succeed' do
237
+ sync_batch = CanvasSync::SyncBatch.create!(status: 'processing', started_at: Time.now)
238
+ status = double('status', success?: false)
239
+
240
+ described_class.batch_completed(status, { 'sync_batch_id' => sync_batch.id })
241
+
242
+ expect(sync_batch.reload.status).to eq('failed')
243
+ end
244
+ end
245
+ end
@@ -62,5 +62,63 @@ RSpec.describe CanvasSync::Job do
62
62
  job_log = CanvasSync::JobLog.last
63
63
  expect(job_log.backtrace).to include "Error Occurred while handling an Error"
64
64
  end
65
+
66
+ it 'reports a failing error handler via Rails.error when available' do
67
+ error_reporter = double("ErrorReporter")
68
+ allow(Rails).to receive(:respond_to?).and_call_original
69
+ allow(Rails).to receive(:respond_to?).with(:error).and_return(true)
70
+ allow(Rails).to receive(:error).and_return(error_reporter)
71
+ expect(error_reporter).to receive(:report).with(instance_of(EvilError), handled: true)
72
+
73
+ set_batch_context(on_failure: 'BadJob.evil_catcher')
74
+ expect {
75
+ BadJob.perform_now()
76
+ }.to raise_exception(StandardError)
77
+ end
78
+
79
+ it 'falls back to Sentry when Rails.error is not available (pre-Rails-7 hosts)' do
80
+ allow(Rails).to receive(:respond_to?).and_call_original
81
+ allow(Rails).to receive(:respond_to?).with(:error).and_return(false)
82
+ sentry = double("Sentry", get_current_scope: nil)
83
+ stub_const("Sentry", sentry)
84
+ expect(sentry).to receive(:capture_exception).with(instance_of(EvilError))
85
+
86
+ set_batch_context(on_failure: 'BadJob.evil_catcher')
87
+ expect {
88
+ BadJob.perform_now()
89
+ }.to raise_exception(StandardError)
90
+ end
91
+
92
+ it 'does not attempt to report anywhere when neither Rails.error nor Sentry is available' do
93
+ allow(Rails).to receive(:respond_to?).and_call_original
94
+ allow(Rails).to receive(:respond_to?).with(:error).and_return(false)
95
+ hide_const("Sentry") if defined?(Sentry)
96
+
97
+ set_batch_context(on_failure: 'BadJob.evil_catcher')
98
+ expect {
99
+ BadJob.perform_now()
100
+ }.to raise_exception(StandardError)
101
+ # No error raised by the reporting guard -- reaching here proves it.
102
+ end
103
+ end
104
+
105
+ describe 'before_enqueue' do
106
+ it 'verifies the ActiveRecord connection before the job is enqueued' do
107
+ ActiveJob::Base.queue_adapter = :test
108
+
109
+ expect(ActiveRecord::Base.connection).to receive(:verify!).once.and_call_original
110
+
111
+ GoodJob.perform_later("argument")
112
+ end
113
+
114
+ it 'still creates a CanvasSync::JobLog after verifying the connection' do
115
+ ActiveJob::Base.queue_adapter = :test
116
+
117
+ expect {
118
+ GoodJob.perform_later("argument")
119
+ }.to change { CanvasSync::JobLog.count }.by(1)
120
+
121
+ expect(CanvasSync::JobLog.last.status).to eq(CanvasSync::JobLog::ENQUEUED_STATUS)
122
+ end
65
123
  end
66
124
  end
@@ -0,0 +1,181 @@
1
+ require 'spec_helper'
2
+
3
+ RSpec.describe CanvasSync::Jobs::TermBatchesJob do
4
+ let(:job) { described_class.new }
5
+ let(:redis_double) { double('redis', get: nil) }
6
+
7
+ before do
8
+ allow(CanvasSync).to receive(:redis).and_return(redis_double)
9
+ allow(CanvasSync::JobBatches::Batch).to receive(:current_context).and_return({ batch_genre: 'default' })
10
+ end
11
+
12
+ # Stubs CanvasSync::JobBatches::ManagedBatchJob.make_batch (or ConcurrentBatchJob.make_batch)
13
+ # to record each invocation and yield a fake "batch proxy" double, without touching real Redis
14
+ # or actually enqueuing anything.
15
+ def stub_make_batch(klass)
16
+ calls = []
17
+ allow(klass).to receive(:make_batch) do |sub_jobs, **kwargs, &blk|
18
+ proxy = double("batch_proxy")
19
+ allow(proxy).to receive(:description=) { |v| calls.last[:description] = v }
20
+ allow(proxy).to receive(:context=) { |v| calls.last[:context] = v }
21
+ allow(proxy).to receive(:on) { |*args| (calls.last[:on] ||= []) << args }
22
+
23
+ calls << { sub_jobs: sub_jobs, kwargs: kwargs }
24
+ blk.call(proxy) if blk
25
+ proxy
26
+ end
27
+ calls
28
+ end
29
+
30
+ describe '#perform' do
31
+ context 'no sub_jobs are given' do
32
+ it 'does nothing' do
33
+ expect(CanvasSync::JobBatches::ManagedBatchJob).to_not receive(:make_batch)
34
+ expect(CanvasSync::JobBatches::ConcurrentBatchJob).to_not receive(:make_batch)
35
+
36
+ job.perform({})
37
+ end
38
+ end
39
+
40
+ context 'term_scope: false' do
41
+ it 'forks all sub_jobs into a single ConcurrentBatchJob covering every term at once' do
42
+ calls = stub_make_batch(CanvasSync::JobBatches::ConcurrentBatchJob)
43
+ expect(CanvasSync::JobBatches::ManagedBatchJob).to_not receive(:make_batch)
44
+ jobs = [{ job: 'SomeJob' }]
45
+
46
+ job.perform(sub_jobs: jobs, term_scope: false, context: { foo: 'bar' })
47
+
48
+ expect(calls.length).to eq(1)
49
+ expect(calls[0][:sub_jobs]).to eq(jobs)
50
+ expect(calls[0][:kwargs]).to eq(context: { foo: 'bar' }, concurrency: 8)
51
+ expect(calls[0][:description]).to eq('All Terms')
52
+ end
53
+ end
54
+
55
+ context 'term_scope is not given' do
56
+ let!(:matching_term) { FactoryBot.create(:term) }
57
+ let!(:other_term) { FactoryBot.create(:term) }
58
+
59
+ it 'forks a ManagedBatchJob per Term returned by CanvasSync.sync_scope(Term)' do
60
+ allow(CanvasSync).to receive(:sync_scope).with(Term).and_return(Term.where(canvas_id: matching_term.canvas_id))
61
+ calls = stub_make_batch(CanvasSync::JobBatches::ManagedBatchJob)
62
+ jobs = [{ job: 'SomeJob' }]
63
+
64
+ job.perform(sub_jobs: jobs)
65
+
66
+ expect(CanvasSync).to have_received(:sync_scope).with(Term)
67
+ expect(calls.length).to eq(1)
68
+ expect(calls[0][:sub_jobs]).to eq(jobs)
69
+ expect(calls[0][:kwargs]).to eq(ordered: false, concurrency: 8)
70
+ expect(calls[0][:description]).to eq("Term##{matching_term.canvas_id} Fork")
71
+ expect(calls[0][:context][:canvas_term_id]).to eq(matching_term.canvas_id)
72
+ end
73
+ end
74
+
75
+ context 'term_scope: true' do
76
+ let!(:matching_term) { FactoryBot.create(:term) }
77
+
78
+ it 'also uses CanvasSync.sync_scope(Term), same as when term_scope is omitted' do
79
+ allow(CanvasSync).to receive(:sync_scope).with(Term).and_return(Term.where(canvas_id: matching_term.canvas_id))
80
+ calls = stub_make_batch(CanvasSync::JobBatches::ManagedBatchJob)
81
+
82
+ job.perform(sub_jobs: [{ job: 'SomeJob' }], term_scope: true)
83
+
84
+ expect(CanvasSync).to have_received(:sync_scope).with(Term)
85
+ expect(calls.length).to eq(1)
86
+ end
87
+ end
88
+
89
+ context 'term_scope is a Symbol' do
90
+ let!(:term) { FactoryBot.create(:term) }
91
+
92
+ it "uses Term.send(term_scope) instead of CanvasSync.sync_scope" do
93
+ allow(Term).to receive(:my_custom_scope).and_return(Term.where(canvas_id: term.canvas_id))
94
+ calls = stub_make_batch(CanvasSync::JobBatches::ManagedBatchJob)
95
+
96
+ job.perform(sub_jobs: [{ job: 'SomeJob' }], term_scope: :my_custom_scope)
97
+
98
+ expect(Term).to have_received(:my_custom_scope)
99
+ expect(calls.length).to eq(1)
100
+ expect(calls[0][:context][:canvas_term_id]).to eq(term.canvas_id)
101
+ end
102
+ end
103
+
104
+ context 'per-term last-sync tracking in Redis' do
105
+ let!(:term) { FactoryBot.create(:term, canvas_id: 55555) }
106
+
107
+ before do
108
+ allow(Term).to receive(:my_scope).and_return(Term.where(canvas_id: term.canvas_id))
109
+ end
110
+
111
+ it "registers a .batch_finished success callback and uses the term-specific redis key" do
112
+ calls = stub_make_batch(CanvasSync::JobBatches::ManagedBatchJob)
113
+
114
+ job.perform(sub_jobs: [{ job: 'SomeJob' }], term_scope: :my_scope)
115
+
116
+ expect(redis_double).to have_received(:get).with("cs:default:55555:last_sync")
117
+ expect(calls[0][:on]).to include([:success, "CanvasSync::Jobs::TermBatchesJob.batch_finished"])
118
+ end
119
+
120
+ it "does not register the success callback when mark_synced: false" do
121
+ calls = stub_make_batch(CanvasSync::JobBatches::ManagedBatchJob)
122
+
123
+ job.perform(sub_jobs: [{ job: 'SomeJob' }], term_scope: :my_scope, mark_synced: false)
124
+
125
+ expect(calls[0][:on]).to be_nil
126
+ end
127
+
128
+ it "overrides the term's updated_after with nil when the term has never been synced" do
129
+ allow(redis_double).to receive(:get).and_return(nil)
130
+ set_batch_context(updated_after: '2024-06-01T00:00:00Z')
131
+ calls = stub_make_batch(CanvasSync::JobBatches::ManagedBatchJob)
132
+
133
+ job.perform(sub_jobs: [{ job: 'SomeJob' }], term_scope: :my_scope)
134
+
135
+ expect(calls[0][:context]).to have_key(:updated_after)
136
+ expect(calls[0][:context][:updated_after]).to be_nil
137
+ end
138
+
139
+ it "overrides the term's updated_after with its own last-sync time when that is more recent than the batch cutoff" do
140
+ allow(redis_double).to receive(:get).and_return('2024-01-01T00:00:00Z')
141
+ set_batch_context(updated_after: '2024-06-01T00:00:00Z')
142
+ calls = stub_make_batch(CanvasSync::JobBatches::ManagedBatchJob)
143
+
144
+ job.perform(sub_jobs: [{ job: 'SomeJob' }], term_scope: :my_scope)
145
+
146
+ expect(calls[0][:context][:updated_after]).to eq('2024-01-01T00:00:00Z')
147
+ end
148
+
149
+ it "leaves the term's context updated_after alone when the term already synced after the batch cutoff" do
150
+ allow(redis_double).to receive(:get).and_return('2024-06-15T00:00:00Z')
151
+ set_batch_context(updated_after: '2024-06-01T00:00:00Z')
152
+ calls = stub_make_batch(CanvasSync::JobBatches::ManagedBatchJob)
153
+
154
+ job.perform(sub_jobs: [{ job: 'SomeJob' }], term_scope: :my_scope)
155
+
156
+ expect(calls[0][:context]).to_not have_key(:updated_after)
157
+ end
158
+ end
159
+ end
160
+
161
+ describe '.batch_finished' do
162
+ it "stores the batch's start time in redis, namespaced by prefix/genre/term" do
163
+ allow(CanvasSync::JobBatches::Batch).to receive(:current_context).and_return({
164
+ canvas_term_id: 42,
165
+ batch_start_time: '2024-01-01T00:00:00Z',
166
+ batch_genre: 'default',
167
+ })
168
+ expect(redis_double).to receive(:set).with('cs:default:42:last_sync', '2024-01-01T00:00:00Z')
169
+
170
+ described_class.batch_finished(double('status'), {})
171
+ end
172
+ end
173
+
174
+ describe '.last_sync_key' do
175
+ it 'builds a redis key namespaced by the configured prefix, the current batch genre, and the term id' do
176
+ allow(CanvasSync::JobBatches::Batch).to receive(:current_context).and_return({ batch_genre: 'nightly' })
177
+
178
+ expect(described_class.last_sync_key(7)).to eq('cs:nightly:7:last_sync')
179
+ end
180
+ end
181
+ end
@@ -35,5 +35,44 @@ RSpec.describe CanvasSync::LiveEvents::ProcessEventJob do
35
35
  expect_any_instance_of(User).to receive(:process_live_event).with(:created, event["payload"], event["metadata"])
36
36
  described_class.perform_now(event)
37
37
  end
38
+
39
+ context "when a handler raises outside the test environment" do
40
+ before do
41
+ hide_const("LiveEvents::UserCreatedEvent")
42
+ allow(Rails.env).to receive(:test?).and_return(false)
43
+ allow_any_instance_of(User).to receive(:process_live_event).and_raise(StandardError, "boom")
44
+ end
45
+
46
+ it "logs the error and reports it via Rails.error when available" do
47
+ error_reporter = double("ErrorReporter")
48
+ allow(Rails).to receive(:respond_to?).and_call_original
49
+ allow(Rails).to receive(:respond_to?).with(:error).and_return(true)
50
+ allow(Rails).to receive(:error).and_return(error_reporter)
51
+ expect(error_reporter).to receive(:report).with(instance_of(StandardError), handled: true)
52
+ expect(Rails.logger).to receive(:error).with(/LiveEvent Handler raised Error/)
53
+
54
+ described_class.perform_now(event)
55
+ end
56
+
57
+ it "falls back to Sentry when Rails.error is not available (pre-Rails-7 hosts)" do
58
+ allow(Rails).to receive(:respond_to?).and_call_original
59
+ allow(Rails).to receive(:respond_to?).with(:error).and_return(false)
60
+ sentry = double("Sentry", get_current_scope: nil)
61
+ stub_const("Sentry", sentry)
62
+ expect(sentry).to receive(:capture_exception).with(instance_of(StandardError))
63
+ expect(Rails.logger).to receive(:error).with(/LiveEvent Handler raised Error/)
64
+
65
+ described_class.perform_now(event)
66
+ end
67
+
68
+ it "does not attempt to report anywhere when neither Rails.error nor Sentry is available" do
69
+ allow(Rails).to receive(:respond_to?).and_call_original
70
+ allow(Rails).to receive(:respond_to?).with(:error).and_return(false)
71
+ hide_const("Sentry") if defined?(Sentry)
72
+ expect(Rails.logger).to receive(:error).with(/LiveEvent Handler raised Error/)
73
+
74
+ described_class.perform_now(event)
75
+ end
76
+ end
38
77
  end
39
78
  end
@@ -0,0 +1,29 @@
1
+ require 'spec_helper'
2
+
3
+ RSpec.describe CanvasSync::JobLog do
4
+ describe 'job_arguments serialization' do
5
+ it 'round-trips a Hash (including nested structures) through the job_arguments column' do
6
+ payload = { 'a' => 1, 'nested' => { 'b' => [1, 2, 3], 'c' => nil } }
7
+
8
+ job_log = CanvasSync::JobLog.create!(
9
+ job_class: 'SomeJob',
10
+ job_id: SecureRandom.uuid,
11
+ status: CanvasSync::JobLog::ENQUEUED_STATUS,
12
+ job_arguments: [payload, 'a string arg', 42],
13
+ )
14
+
15
+ reloaded = CanvasSync::JobLog.find(job_log.id)
16
+ expect(reloaded.job_arguments).to eq([payload, 'a string arg', 42])
17
+ end
18
+
19
+ it 'defaults to an empty Array when no job_arguments are given' do
20
+ job_log = CanvasSync::JobLog.create!(
21
+ job_class: 'SomeJob',
22
+ job_id: SecureRandom.uuid,
23
+ status: CanvasSync::JobLog::ENQUEUED_STATUS,
24
+ )
25
+
26
+ expect(CanvasSync::JobLog.find(job_log.id).job_arguments).to eq([])
27
+ end
28
+ end
29
+ end
data/spec/spec_helper.rb CHANGED
@@ -1,3 +1,8 @@
1
+ require 'simplecov'
2
+ SimpleCov.start do
3
+ add_filter '/spec/'
4
+ end
5
+
1
6
  ENV["RAILS_ENV"] ||= 'test'
2
7
  ENV['REDIS_URL'] ||= 'redis://localhost:6379/9'
3
8
 
@@ -6,6 +11,7 @@ Bundler.require :default, :development
6
11
 
7
12
  Combustion::Application.configure do
8
13
  config.enable_reloading = true # Required for with_model
14
+ config.active_support.deprecation = :raise
9
15
  end
10
16
 
11
17
  # Register application/jwt MIME type for testing
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: canvas_sync
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.27.5
4
+ version: 0.27.7
5
5
  platform: ruby
6
6
  authors:
7
7
  - Instructure CustomDev
@@ -121,6 +121,20 @@ dependencies:
121
121
  - - ">="
122
122
  - !ruby/object:Gem::Version
123
123
  version: '0'
124
+ - !ruby/object:Gem::Dependency
125
+ name: simplecov
126
+ requirement: !ruby/object:Gem::Requirement
127
+ requirements:
128
+ - - ">="
129
+ - !ruby/object:Gem::Version
130
+ version: '0'
131
+ type: :development
132
+ prerelease: false
133
+ version_requirements: !ruby/object:Gem::Requirement
134
+ requirements:
135
+ - - ">="
136
+ - !ruby/object:Gem::Version
137
+ version: '0'
124
138
  - !ruby/object:Gem::Dependency
125
139
  name: shoulda-matchers
126
140
  requirement: !ruby/object:Gem::Requirement
@@ -275,20 +289,6 @@ dependencies:
275
289
  - - ">="
276
290
  - !ruby/object:Gem::Version
277
291
  version: '0'
278
- - !ruby/object:Gem::Dependency
279
- name: open-uri
280
- requirement: !ruby/object:Gem::Requirement
281
- requirements:
282
- - - ">="
283
- - !ruby/object:Gem::Version
284
- version: '0'
285
- type: :runtime
286
- prerelease: false
287
- version_requirements: !ruby/object:Gem::Requirement
288
- requirements:
289
- - - ">="
290
- - !ruby/object:Gem::Version
291
- version: '0'
292
292
  - !ruby/object:Gem::Dependency
293
293
  name: activejob
294
294
  requirement: !ruby/object:Gem::Requirement
@@ -421,26 +421,6 @@ dependencies:
421
421
  - - "~>"
422
422
  - !ruby/object:Gem::Version
423
423
  version: 0.1.0
424
- - !ruby/object:Gem::Dependency
425
- name: colorize
426
- requirement: !ruby/object:Gem::Requirement
427
- requirements:
428
- - - "~>"
429
- - !ruby/object:Gem::Version
430
- version: '1.0'
431
- - - "<"
432
- - !ruby/object:Gem::Version
433
- version: '2.0'
434
- type: :runtime
435
- prerelease: false
436
- version_requirements: !ruby/object:Gem::Requirement
437
- requirements:
438
- - - "~>"
439
- - !ruby/object:Gem::Version
440
- version: '1.0'
441
- - - "<"
442
- - !ruby/object:Gem::Version
443
- version: '2.0'
444
424
  email:
445
425
  - pseng@instructure.com
446
426
  executables: []
@@ -589,6 +569,10 @@ files:
589
569
  - lib/canvas_sync/version.rb
590
570
  - lib/tasks/canvas_sync_tasks.rake
591
571
  - spec/canvas_sync/canvas_sync_spec.rb
572
+ - spec/canvas_sync/concerns/deleted_scoping_spec.rb
573
+ - spec/canvas_sync/concerns/legacy_columns_spec.rb
574
+ - spec/canvas_sync/engine_spec.rb
575
+ - spec/canvas_sync/jobs/begin_sync_chain_job_spec.rb
592
576
  - spec/canvas_sync/jobs/canvas_process_waiter_spec.rb
593
577
  - spec/canvas_sync/jobs/job_spec.rb
594
578
  - spec/canvas_sync/jobs/report_starter_spec.rb
@@ -598,6 +582,7 @@ files:
598
582
  - spec/canvas_sync/jobs/sync_roles_job_spec.rb
599
583
  - spec/canvas_sync/jobs/sync_simple_table_job_spec.rb
600
584
  - spec/canvas_sync/jobs/sync_terms_job_spec.rb
585
+ - spec/canvas_sync/jobs/term_batches_job_spec.rb
601
586
  - spec/canvas_sync/live_events/live_event_sync_spec.rb
602
587
  - spec/canvas_sync/live_events/live_events_controller_spec.rb
603
588
  - spec/canvas_sync/live_events/process_event_job_spec.rb
@@ -612,6 +597,7 @@ files:
612
597
  - spec/canvas_sync/models/enrollment_spec.rb
613
598
  - spec/canvas_sync/models/group_membership_spec.rb
614
599
  - spec/canvas_sync/models/group_spec.rb
600
+ - spec/canvas_sync/models/job_log_spec.rb
615
601
  - spec/canvas_sync/models/roles_spec.rb
616
602
  - spec/canvas_sync/models/section_spec.rb
617
603
  - spec/canvas_sync/models/submission_spec.rb
@@ -782,6 +768,10 @@ specification_version: 4
782
768
  summary: Gem for generating Canvas models and migrations and syncing data from Canvas
783
769
  test_files:
784
770
  - spec/canvas_sync/canvas_sync_spec.rb
771
+ - spec/canvas_sync/concerns/deleted_scoping_spec.rb
772
+ - spec/canvas_sync/concerns/legacy_columns_spec.rb
773
+ - spec/canvas_sync/engine_spec.rb
774
+ - spec/canvas_sync/jobs/begin_sync_chain_job_spec.rb
785
775
  - spec/canvas_sync/jobs/canvas_process_waiter_spec.rb
786
776
  - spec/canvas_sync/jobs/job_spec.rb
787
777
  - spec/canvas_sync/jobs/report_starter_spec.rb
@@ -791,6 +781,7 @@ test_files:
791
781
  - spec/canvas_sync/jobs/sync_roles_job_spec.rb
792
782
  - spec/canvas_sync/jobs/sync_simple_table_job_spec.rb
793
783
  - spec/canvas_sync/jobs/sync_terms_job_spec.rb
784
+ - spec/canvas_sync/jobs/term_batches_job_spec.rb
794
785
  - spec/canvas_sync/live_events/live_event_sync_spec.rb
795
786
  - spec/canvas_sync/live_events/live_events_controller_spec.rb
796
787
  - spec/canvas_sync/live_events/process_event_job_spec.rb
@@ -805,6 +796,7 @@ test_files:
805
796
  - spec/canvas_sync/models/enrollment_spec.rb
806
797
  - spec/canvas_sync/models/group_membership_spec.rb
807
798
  - spec/canvas_sync/models/group_spec.rb
799
+ - spec/canvas_sync/models/job_log_spec.rb
808
800
  - spec/canvas_sync/models/roles_spec.rb
809
801
  - spec/canvas_sync/models/section_spec.rb
810
802
  - spec/canvas_sync/models/submission_spec.rb