job_spec.rb 23 KB

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