job_spec.rb 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606
  1. # Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
  2. require 'rails_helper'
  3. require 'models/application_model_examples'
  4. require 'models/concerns/has_xss_sanitized_note_examples'
  5. require 'models/concerns/has_timeplan_examples'
  6. RSpec.describe Job, type: :model do
  7. subject(:job) { create(:job) }
  8. it_behaves_like 'ApplicationModel', can_assets: { selectors: %i[condition perform] }
  9. it_behaves_like 'HasXssSanitizedNote', model_factory: :job
  10. it_behaves_like 'HasTimeplan'
  11. describe 'Class methods:' do
  12. describe '.run' do
  13. let!(:executable_jobs) { jobs.select(&:executable?).select(&:in_timeplan?) }
  14. let!(:nonexecutable_jobs) { jobs - executable_jobs }
  15. let!(:jobs) do
  16. [
  17. # executable
  18. create(:job, :always_on, updated_at: 2.minutes.ago),
  19. # not executable (updated too recently)
  20. create(:job),
  21. # not executable (inactive)
  22. create(:job, updated_at: 2.minutes.ago, active: false),
  23. # not executable (started too recently)
  24. create(:job, :always_on, updated_at: 2.minutes.ago, last_run_at: 5.minutes.ago),
  25. # executable
  26. create(:job, :always_on, updated_at: 2.minutes.ago, last_run_at: 15.minutes.ago),
  27. # not executable (still running, started too recently)
  28. create(:job, :always_on, updated_at: 2.minutes.ago, running: true, last_run_at: 23.hours.ago),
  29. # executable
  30. create(:job, :always_on, updated_at: 2.minutes.ago, running: true, last_run_at: 25.hours.ago),
  31. ]
  32. end
  33. it 'runs all executable jobs (and no others)' do
  34. expect { described_class.run }
  35. .to change { executable_jobs.map(&:reload).map(&:last_run_at).any?(&:nil?) }.to(false)
  36. .and not_change { nonexecutable_jobs.map(&:reload).map(&:last_run_at).all?(&:nil?) }
  37. end
  38. end
  39. end
  40. describe 'Instance methods:' do
  41. describe '#run' do
  42. subject(:job) { create(:job, condition: condition, perform: perform) }
  43. let(:condition) do
  44. {
  45. 'ticket.state_id' => {
  46. 'operator' => 'is',
  47. 'value' => Ticket::State.where(name: %w[new open]).pluck(:id).map(&:to_s)
  48. },
  49. 'ticket.created_at' => {
  50. 'operator' => 'before (relative)',
  51. 'value' => '2',
  52. 'range' => 'day'
  53. },
  54. }
  55. end
  56. let(:perform) do
  57. { 'ticket.state_id' => { 'value' => Ticket::State.find_by(name: 'closed').id.to_s } }
  58. end
  59. let!(:matching_ticket) do
  60. create(:ticket, state: Ticket::State.lookup(name: 'new'), created_at: 3.days.ago)
  61. end
  62. let!(:nonmatching_ticket) do
  63. create(:ticket, state: Ticket::State.lookup(name: 'new'), created_at: 1.day.ago)
  64. end
  65. context 'when job is not #executable?' do
  66. before { allow(job).to receive(:executable?).and_return(false) }
  67. it 'does not perform changes on matching tickets' do
  68. expect { job.run }.not_to change { matching_ticket.reload.state }
  69. end
  70. it 'does not update #last_run_at' do
  71. expect { job.run }.to not_change { job.reload.last_run_at }
  72. end
  73. context 'but "force" flag is given' do
  74. it 'performs changes on matching tickets' do
  75. expect { job.run(true) }
  76. .to change { matching_ticket.reload.state }
  77. .and not_change { nonmatching_ticket.reload.state }
  78. end
  79. it 'updates #last_run_at' do
  80. expect { job.run(true) }
  81. .to change { job.reload.last_run_at }
  82. end
  83. end
  84. end
  85. context 'when job is #executable?' do
  86. before { allow(job).to receive(:executable?).and_return(true) }
  87. context 'and due (#in_timeplan?)' do
  88. before { allow(job).to receive(:in_timeplan?).and_return(true) }
  89. it 'updates #last_run_at' do
  90. expect { job.run }.to change { job.reload.last_run_at }
  91. end
  92. it 'performs changes on matching tickets' do
  93. expect { job.run }
  94. .to change { matching_ticket.reload.state }
  95. .and not_change { nonmatching_ticket.reload.state }
  96. end
  97. context 'and already marked #running' do
  98. before { job.update(running: true) }
  99. it 'resets #running to false' do
  100. expect { job.run }.to change { job.reload.running }.to(false)
  101. end
  102. end
  103. context 'but condition doesn’t match any tickets' do
  104. before { job.update(condition: invalid_condition) }
  105. let(:invalid_condition) do
  106. { 'ticket.state_id' => { 'operator' => 'is', 'value' => '9999' } }
  107. end
  108. it 'performs no changes' do
  109. expect { job.run }
  110. .not_to change { matching_ticket.reload.state }
  111. end
  112. end
  113. describe 'use case: deleting tickets based on tag' do
  114. let(:condition) { { 'ticket.tags' => { 'operator' => 'contains one', 'value' => 'spam' } } }
  115. let(:perform) { { 'ticket.action' => { 'value' => 'delete' } } }
  116. let!(:matching_ticket) { create(:ticket).tap { |t| t.tag_add('spam', 1) } }
  117. let!(:nonmatching_ticket) { create(:ticket) }
  118. it 'deletes tickets matching the specified tags' do
  119. job.run
  120. expect { matching_ticket.reload }.to raise_error(ActiveRecord::RecordNotFound)
  121. expect { nonmatching_ticket.reload }.not_to raise_error
  122. end
  123. end
  124. describe 'use case: deleting tickets based on group' do
  125. let(:condition) { { 'ticket.group_id' => { 'operator' => 'is', 'value' => matching_ticket.group.id } } }
  126. let(:perform) { { 'ticket.action' => { 'value' => 'delete' } } }
  127. let!(:matching_ticket) { create(:ticket) }
  128. let!(:nonmatching_ticket) { create(:ticket) }
  129. it 'deletes tickets matching the specified groups' do
  130. job.run
  131. expect { matching_ticket.reload }.to raise_error(ActiveRecord::RecordNotFound)
  132. expect { nonmatching_ticket.reload }.not_to raise_error
  133. end
  134. end
  135. end
  136. context 'and not due yet' do
  137. before { allow(job).to receive(:in_timeplan?).and_return(false) }
  138. it 'does not perform changes on matching tickets' do
  139. expect { job.run }.not_to change { matching_ticket.reload.state }
  140. end
  141. it 'does not update #last_run_at' do
  142. expect { job.run }.to not_change { job.reload.last_run_at }
  143. end
  144. it 'updates #next_run_at' do
  145. travel_to(Time.current.last_week) # force new value for #next_run_at
  146. expect { job.run }.to change { job.reload.next_run_at }
  147. end
  148. context 'but "force" flag is given' do
  149. it 'performs changes on matching tickets' do
  150. expect { job.run(true) }
  151. .to change { matching_ticket.reload.state }
  152. .and not_change { nonmatching_ticket.reload.state }
  153. end
  154. it 'updates #last_run_at' do
  155. expect { job.run(true) }.to change { job.reload.last_run_at }
  156. end
  157. it 'updates #next_run_at' do
  158. travel_to(Time.current.last_week) # force new value for #next_run_at
  159. expect { job.run }.to change { job.reload.next_run_at }
  160. end
  161. end
  162. end
  163. end
  164. context 'when job has pre_condition:current_user.id in selector' do
  165. let!(:matching_ticket) { create(:ticket, owner_id: 1) }
  166. let!(:nonmatching_ticket) { create(:ticket, owner_id: create(:agent).id) }
  167. let(:condition) do
  168. {
  169. 'ticket.owner_id' => {
  170. 'operator' => 'is',
  171. 'pre_condition' => 'current_user.id',
  172. 'value' => '',
  173. 'value_completion' => ''
  174. },
  175. }
  176. end
  177. before do
  178. UserInfo.current_user_id = create(:admin).id
  179. job
  180. UserInfo.current_user_id = nil
  181. end
  182. it 'performs changes on matching tickets' do
  183. expect { job.run(true) }
  184. .to change { matching_ticket.reload.state }
  185. .and not_change { nonmatching_ticket.reload.state }
  186. end
  187. end
  188. end
  189. describe '#executable?' do
  190. context 'for an inactive Job' do
  191. subject(:job) { create(:job, active: false) }
  192. it 'returns false' do
  193. expect(job.executable?).to be(false)
  194. end
  195. end
  196. context 'for an active job' do
  197. context 'updated in the last minute' do
  198. subject(:job) do
  199. create(:job, active: true,
  200. updated_at: 59.seconds.ago)
  201. end
  202. it 'returns false' do
  203. expect(job.executable?).to be(false)
  204. end
  205. end
  206. context 'updated over a minute ago' do
  207. context 'that has never run before' do
  208. subject(:job) do
  209. create(:job, active: true,
  210. updated_at: 60.seconds.ago)
  211. end
  212. it 'returns true' do
  213. expect(job.executable?).to be(true)
  214. end
  215. end
  216. context 'that was started in the last 10 minutes' do
  217. subject(:job) do
  218. create(:job, active: true,
  219. updated_at: 60.seconds.ago,
  220. last_run_at: 9.minutes.ago)
  221. end
  222. it 'returns false' do
  223. expect(job.executable?).to be(false)
  224. end
  225. context '(or, given an argument, up to 10 minutes before that)' do
  226. subject(:job) do
  227. create(:job, active: true,
  228. updated_at: 60.seconds.ago,
  229. last_run_at: 9.minutes.before(Time.current.yesterday))
  230. end
  231. it 'returns false' do
  232. expect(job.executable?(Time.current.yesterday)).to be(false)
  233. end
  234. end
  235. end
  236. context 'that was started over 10 minutes ago' do
  237. subject(:job) do
  238. create(:job, active: true,
  239. updated_at: 60.seconds.ago,
  240. last_run_at: 10.minutes.ago)
  241. end
  242. it 'returns true' do
  243. expect(job.executable?).to be(true)
  244. end
  245. context '(or, given an argument, over 10 minutes before that)' do
  246. subject(:job) do
  247. create(:job, active: true,
  248. updated_at: 60.seconds.ago,
  249. last_run_at: 10.minutes.before(Time.current.yesterday))
  250. end
  251. it 'returns true' do
  252. expect(job.executable?(Time.current.yesterday)).to be(true)
  253. end
  254. end
  255. context 'but is still running, up to 24 hours later' do
  256. subject(:job) do
  257. create(:job, active: true,
  258. updated_at: 60.seconds.ago,
  259. running: true,
  260. last_run_at: 23.hours.ago)
  261. end
  262. it 'returns false' do
  263. expect(job.executable?).to be(false)
  264. end
  265. end
  266. context 'but is still running, over 24 hours later' do
  267. subject(:job) do
  268. create(:job, active: true,
  269. updated_at: 60.seconds.ago,
  270. running: true,
  271. last_run_at: 24.hours.ago)
  272. end
  273. it 'returns true' do
  274. expect(job.executable?).to be(true)
  275. end
  276. end
  277. end
  278. end
  279. end
  280. end
  281. end
  282. describe 'Attributes:' do
  283. describe '#next_run_at' do
  284. subject(:job) { build(:job) }
  285. it 'is set automatically on save (cannot be set manually)' do
  286. job.next_run_at = 1.day.from_now
  287. expect { job.save }.to change(job, :next_run_at)
  288. end
  289. context 'for an inactive Job' do
  290. subject(:job) { build(:job, active: false) }
  291. it 'is nil' do
  292. expect { job.save }
  293. .not_to change(job, :next_run_at).from(nil)
  294. end
  295. end
  296. context 'for a never-on Job (all #timeplan values are false)' do
  297. subject(:job) { build(:job, :never_on) }
  298. it 'is nil' do
  299. expect { job.save }
  300. .not_to change(job, :next_run_at).from(nil)
  301. end
  302. end
  303. context 'when #timeplan contains at least one true value for :day, :hour, and :minute' do
  304. subject(:job) { build(:job, :never_on) }
  305. let(:base_time) { Time.current.beginning_of_week }
  306. # Tuesday & Thursday @ 12:00a, 12:30a, 6:00p, and 6:30p
  307. before do
  308. job.assign_attributes(
  309. timeplan: {
  310. days: job.timeplan[:days].merge(Tue: true, Thu: true),
  311. hours: job.timeplan[:hours].merge(0 => true, 18 => true),
  312. minutes: job.timeplan[:minutes].merge(0 => true, 30 => true),
  313. }
  314. )
  315. end
  316. let(:valid_timeslots) do
  317. [
  318. base_time + 1.day, # Tue 12:00a
  319. base_time + 1.day + 30.minutes, # Tue 12:30a
  320. base_time + 1.day + 18.hours, # Tue 6:00p
  321. base_time + 1.day + 18.hours + 30.minutes, # Tue 6:30p
  322. base_time + 3.days, # Thu 12:00a
  323. base_time + 3.days + 30.minutes, # Thu 12:30a
  324. base_time + 3.days + 18.hours, # Thu 6:00p
  325. base_time + 3.days + 18.hours + 30.minutes, # Thu 6:30p
  326. ]
  327. end
  328. context 'for a Job that has never been run before' do
  329. context 'when record is saved at the start of the week' do
  330. before { travel_to(base_time) }
  331. it 'is set to the first valid timeslot of the week' do
  332. expect { job.save }
  333. .to change { job.next_run_at.to_i } # comparing times is hard;
  334. .to(valid_timeslots.first.to_i) # integers are less precise
  335. end
  336. end
  337. context 'when record is saved between two valid timeslots' do
  338. before { travel_to(valid_timeslots.third - 1.second) }
  339. it 'is set to the latter timeslot' do
  340. expect { job.save }
  341. .to change { job.next_run_at.to_i } # comparing times is hard;
  342. .to(valid_timeslots.third.to_i) # integers are less precise
  343. end
  344. end
  345. context 'when record is saved during a valid timeslot' do
  346. before { travel_to(valid_timeslots.fifth + 9.minutes + 59.seconds) }
  347. it 'is set to that timeslot' do
  348. expect { job.save }
  349. .to change { job.next_run_at.to_i } # comparing times is hard;
  350. .to(valid_timeslots.fifth.to_i) # integers are less precise
  351. end
  352. end
  353. end
  354. context 'for a Job that been run before' do
  355. context 'when record is saved in the same timeslot as #last_run_at' do
  356. before do
  357. job.assign_attributes(last_run_at: valid_timeslots.fourth + 5.minutes)
  358. travel_to(valid_timeslots.fourth + 7.minutes)
  359. end
  360. it 'is set to the next valid timeslot' do
  361. expect { job.save }
  362. .to change { job.next_run_at.to_i } # comparing times is hard;
  363. .to(valid_timeslots.fifth.to_i) # integers are less precise
  364. end
  365. end
  366. end
  367. end
  368. context 'when updating #next_run_at' do
  369. before do
  370. travel_to Time.zone.parse('2020-12-27 00:00')
  371. job.timeplan = { days: { Mon: true }, hours: { 0 => true }, minutes: { 0 => true } }
  372. end
  373. it 'sets #next_run_at time in selected time zone' do
  374. Setting.set 'timezone_default', 'Europe/Vilnius'
  375. expect { job.save }.to change(job, :next_run_at).to(Time.zone.parse('2020-12-27 22:00'))
  376. end
  377. it 'sets #next_run_at time in UTC' do
  378. expect { job.save }.to change(job, :next_run_at).to(Time.zone.parse('2020-12-28 00:00'))
  379. end
  380. end
  381. end
  382. describe '#perform' do
  383. describe 'Validations:' do
  384. describe '"article.note" key' do
  385. let(:perform) do
  386. { 'article.note' => { 'subject' => 'foo', 'internal' => 'true', 'body' => '' } }
  387. end
  388. it 'fails if an empty "body" is given' do
  389. expect { create(:job, perform: perform) }.to raise_error(Exceptions::UnprocessableEntity)
  390. end
  391. end
  392. describe '"notification.email" key' do
  393. let(:perform) do
  394. { 'notification.email' => { 'body' => 'foo', 'recipient' => '', 'subject' => 'bar' } }
  395. end
  396. it 'fails if an empty "recipient" is given' do
  397. expect { create(:job, perform: perform) }.to raise_error(Exceptions::UnprocessableEntity)
  398. end
  399. end
  400. describe '"notification.sms" key' do
  401. let(:perform) do
  402. { 'notification.sms' => { 'body' => 'foo', 'recipient' => '' } }
  403. end
  404. it 'fails if an empty "recipient" is given' do
  405. expect { create(:job, perform: perform) }.to raise_error(Exceptions::UnprocessableEntity)
  406. end
  407. end
  408. end
  409. end
  410. end
  411. # when running a very large job, tickets may change during the job
  412. # if tickets are fetched once, their action may be performed later on
  413. # when it no longer matches the conditions
  414. # https://github.com/zammad/zammad/issues/3329
  415. context 'job re-checks conditions' do
  416. let(:job) { create(:job, condition: condition, perform: perform) }
  417. let(:ticket) { create(:ticket, title: initial_title) }
  418. let(:initial_title) { 'initial 3329' }
  419. let(:changed_title) { 'performed 3329' }
  420. let(:condition) do
  421. { 'ticket.title' => { 'value' => initial_title, 'operator' => 'is' } }
  422. end
  423. let(:perform) do
  424. { 'ticket.title' => { 'value'=> changed_title } }
  425. end
  426. it 'condition matches ticket' do
  427. ticket
  428. expect(job.send(:start_job, Time.zone.now, true)).to eq [ticket.id]
  429. end
  430. it 'action is performed' do
  431. ticket
  432. ticket_ids = job.send(:start_job, Time.zone.now, true)
  433. job.send(:run_slice, ticket_ids)
  434. expect(ticket.reload.title).to eq changed_title
  435. end
  436. it 'checks conditions' do
  437. ticket
  438. ticket_ids = job.send(:start_job, Time.zone.now, true)
  439. ticket.update! title: 'another title'
  440. job.send(:run_slice, ticket_ids)
  441. expect(ticket.reload.title).not_to eq changed_title
  442. end
  443. end
  444. describe 'Scheduler ignores "disable notifications == no" #3684', sends_notification_emails: true do
  445. let!(:group) { create(:group) }
  446. let!(:agent) { create(:agent, groups: [group]) }
  447. let!(:ticket) { create(:ticket, group: group, owner: agent) }
  448. let(:perform) do
  449. { 'article.note' => { 'body' => 'ccc', 'internal' => 'true', 'subject' => 'ccc' }, 'ticket.state_id' => { 'value' => 4 } }
  450. end
  451. context 'with disable_notification true' do
  452. let!(:notify_job) { create(:job, :always_on) }
  453. it 'does modify the ticket' do
  454. expect { notify_job.run(true) }.to change { ticket.reload.state }
  455. end
  456. it 'does not send a notification to the owner of the ticket' do # rubocop:disable RSpec/ExampleLength
  457. check_notification do
  458. notify_job.run(true)
  459. Scheduler.worker(true)
  460. not_sent(
  461. template: 'ticket_update',
  462. user: agent,
  463. objects: hash_including({ article: nil })
  464. )
  465. end
  466. end
  467. end
  468. context 'with disable_notification false' do
  469. let!(:notify_job) { create(:job, :always_on, disable_notification: false, perform: perform) }
  470. it 'does modify the ticket' do
  471. expect { notify_job.run(true) }.to change { ticket.reload.state }
  472. end
  473. it 'does send a notification to the owner of the ticket with trigger note in notification body' do # rubocop:disable RSpec/ExampleLength
  474. check_notification do
  475. notify_job.run(true)
  476. Scheduler.worker(true)
  477. sent(
  478. template: 'ticket_update',
  479. user: agent,
  480. objects: hash_including({ article: ticket.reload.articles.first })
  481. )
  482. end
  483. end
  484. end
  485. end
  486. end