job_spec.rb 21 KB

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