job_spec.rb 23 KB

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