canvas_sync 0.27.6 → 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.
- checksums.yaml +4 -4
- data/lib/canvas_sync/concerns/deleted_scoping.rb +2 -4
- data/lib/canvas_sync/config.rb +5 -3
- data/lib/canvas_sync/engine.rb +2 -2
- data/lib/canvas_sync/generators/templates/models/assignment_override.rb +1 -1
- data/lib/canvas_sync/generators/templates/models/context_module.rb +2 -2
- data/lib/canvas_sync/generators/templates/models/context_module_item.rb +1 -1
- data/lib/canvas_sync/generators/templates/models/course_progress.rb +1 -1
- data/lib/canvas_sync/generators/templates/models/learning_outcome.rb +1 -1
- data/lib/canvas_sync/generators/templates/models/learning_outcome_result.rb +1 -1
- data/lib/canvas_sync/importers/bulk_importer.rb +2 -2
- data/lib/canvas_sync/job.rb +5 -1
- data/lib/canvas_sync/jobs/sync_provisioning_report_job.rb +5 -1
- data/lib/canvas_sync/jobs/sync_submissions_job.rb +1 -1
- data/lib/canvas_sync/live_events/process_event_job.rb +5 -1
- data/lib/canvas_sync/version.rb +1 -1
- data/lib/canvas_sync.rb +2 -2
- data/spec/canvas_sync/canvas_sync_spec.rb +88 -0
- data/spec/canvas_sync/concerns/deleted_scoping_spec.rb +46 -0
- data/spec/canvas_sync/concerns/legacy_columns_spec.rb +59 -0
- data/spec/canvas_sync/engine_spec.rb +25 -0
- data/spec/canvas_sync/jobs/begin_sync_chain_job_spec.rb +245 -0
- data/spec/canvas_sync/jobs/job_spec.rb +58 -0
- data/spec/canvas_sync/jobs/term_batches_job_spec.rb +181 -0
- data/spec/canvas_sync/live_events/process_event_job_spec.rb +39 -0
- data/spec/canvas_sync/models/job_log_spec.rb +29 -0
- data/spec/spec_helper.rb +6 -0
- metadata +27 -15
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bc79a9e2241e08965571784e4078895a0be737b2075d9dc7068cee78fee90a39
|
|
4
|
+
data.tar.gz: 45a423897e00e97f90a785374ca10d967275874b61b1cb9a2afcfd69dccbb3bd
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7389e3c5fdc2dcb65b2330b19d5850298dddcc8fcc1ed57873f79594ad5bae59c2754ab6b1f1ef8350e9d2cbe8442e2631ca4dd7d90eb64ef4be1e6909245d7a
|
|
7
|
+
data.tar.gz: 584dfdd57d8ff294560c62c584b46660f0177e3841a719bee167b13b879418c2553de007baad1b1866ad459234880219038d5037805da331b4a230ac5e2b3e07
|
|
@@ -11,10 +11,8 @@ module CanvasSync::Concerns
|
|
|
11
11
|
|
|
12
12
|
included do
|
|
13
13
|
default_scope do
|
|
14
|
-
|
|
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
|
data/lib/canvas_sync/config.rb
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
module CanvasSync
|
|
2
2
|
class Config
|
|
3
|
-
|
|
3
|
+
attr_accessor :classes_to_only_log_errors_on, :redis_key_prefix
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
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
|
data/lib/canvas_sync/engine.rb
CHANGED
|
@@ -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
|
|
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
|
|
data/lib/canvas_sync/job.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
data/lib/canvas_sync/version.rb
CHANGED
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?(
|
|
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
|
|
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.
|
|
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
|
|
@@ -569,6 +569,10 @@ files:
|
|
|
569
569
|
- lib/canvas_sync/version.rb
|
|
570
570
|
- lib/tasks/canvas_sync_tasks.rake
|
|
571
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
|
|
572
576
|
- spec/canvas_sync/jobs/canvas_process_waiter_spec.rb
|
|
573
577
|
- spec/canvas_sync/jobs/job_spec.rb
|
|
574
578
|
- spec/canvas_sync/jobs/report_starter_spec.rb
|
|
@@ -578,6 +582,7 @@ files:
|
|
|
578
582
|
- spec/canvas_sync/jobs/sync_roles_job_spec.rb
|
|
579
583
|
- spec/canvas_sync/jobs/sync_simple_table_job_spec.rb
|
|
580
584
|
- spec/canvas_sync/jobs/sync_terms_job_spec.rb
|
|
585
|
+
- spec/canvas_sync/jobs/term_batches_job_spec.rb
|
|
581
586
|
- spec/canvas_sync/live_events/live_event_sync_spec.rb
|
|
582
587
|
- spec/canvas_sync/live_events/live_events_controller_spec.rb
|
|
583
588
|
- spec/canvas_sync/live_events/process_event_job_spec.rb
|
|
@@ -592,6 +597,7 @@ files:
|
|
|
592
597
|
- spec/canvas_sync/models/enrollment_spec.rb
|
|
593
598
|
- spec/canvas_sync/models/group_membership_spec.rb
|
|
594
599
|
- spec/canvas_sync/models/group_spec.rb
|
|
600
|
+
- spec/canvas_sync/models/job_log_spec.rb
|
|
595
601
|
- spec/canvas_sync/models/roles_spec.rb
|
|
596
602
|
- spec/canvas_sync/models/section_spec.rb
|
|
597
603
|
- spec/canvas_sync/models/submission_spec.rb
|
|
@@ -762,6 +768,10 @@ specification_version: 4
|
|
|
762
768
|
summary: Gem for generating Canvas models and migrations and syncing data from Canvas
|
|
763
769
|
test_files:
|
|
764
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
|
|
765
775
|
- spec/canvas_sync/jobs/canvas_process_waiter_spec.rb
|
|
766
776
|
- spec/canvas_sync/jobs/job_spec.rb
|
|
767
777
|
- spec/canvas_sync/jobs/report_starter_spec.rb
|
|
@@ -771,6 +781,7 @@ test_files:
|
|
|
771
781
|
- spec/canvas_sync/jobs/sync_roles_job_spec.rb
|
|
772
782
|
- spec/canvas_sync/jobs/sync_simple_table_job_spec.rb
|
|
773
783
|
- spec/canvas_sync/jobs/sync_terms_job_spec.rb
|
|
784
|
+
- spec/canvas_sync/jobs/term_batches_job_spec.rb
|
|
774
785
|
- spec/canvas_sync/live_events/live_event_sync_spec.rb
|
|
775
786
|
- spec/canvas_sync/live_events/live_events_controller_spec.rb
|
|
776
787
|
- spec/canvas_sync/live_events/process_event_job_spec.rb
|
|
@@ -785,6 +796,7 @@ test_files:
|
|
|
785
796
|
- spec/canvas_sync/models/enrollment_spec.rb
|
|
786
797
|
- spec/canvas_sync/models/group_membership_spec.rb
|
|
787
798
|
- spec/canvas_sync/models/group_spec.rb
|
|
799
|
+
- spec/canvas_sync/models/job_log_spec.rb
|
|
788
800
|
- spec/canvas_sync/models/roles_spec.rb
|
|
789
801
|
- spec/canvas_sync/models/section_spec.rb
|
|
790
802
|
- spec/canvas_sync/models/submission_spec.rb
|