job_spec.rb 22 KB

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