├── .formatter.exs ├── .gitignore ├── .travis.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── config ├── config.exs ├── dev.exs ├── production.exs └── test.exs ├── lib ├── cronex.ex └── cronex │ ├── every.ex │ ├── job.ex │ ├── parser.ex │ ├── scheduler.ex │ ├── table.ex │ ├── test.ex │ └── test │ └── date_time.ex ├── mix.exs ├── mix.lock └── test ├── cronex ├── job_test.exs ├── parser_test.exs ├── scheduler_test.exs └── table_test.exs ├── cronex_test.exs └── test_helper.exs /.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 3 | ] 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps 9 | 10 | # Where 3rd-party dependencies like ExDoc output generated docs. 11 | /doc 12 | 13 | # If the VM crashes, it generates a dump, let's ignore it too. 14 | erl_crash.dump 15 | 16 | # Also ignore archive artifacts (built via "mix archive.build"). 17 | *.ez 18 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: elixir 2 | 3 | elixir: 4 | - 1.7.0 5 | - 1.7.1 6 | - 1.7.2 7 | - 1.7.3 8 | 9 | otp_release: 10 | - 19.3 11 | - 20.3 12 | - 21.1 13 | 14 | before_script: mix format --check-formatted 15 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change log 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | ## [Unreleased] 6 | 7 | ### Fixed 8 | - Dependency on `Miv.env` that is not available in production (#4) 9 | 10 | ### Changed 11 | - Starting a `Cronex.Table` always requires a valid `Cronex.Scheduler` 12 | - Minimum Elixir version is now 1.7 (#18) 13 | 14 | ### Improved 15 | - `Cronex.Table` documentation 16 | 17 | ## Version 0.4 - 2017/02/10 18 | 19 | ### Added 20 | - Support for interval time based jobs 21 | 22 | ### Changed 23 | - Minimum Elixir version is now 1.4 24 | 25 | ### Removed 26 | - `Cronex.DateTime` module 27 | 28 | ## Version 0.3 - 2016/12/29 29 | 30 | ### Added 31 | - Support for week days 32 | - Job validation 33 | - `Cronex.Test` module with test helpers 34 | 35 | ### Improved 36 | - Overall project documentation 37 | - `Job.can_run?` tests 38 | - `Scheduler` tests 39 | 40 | ## Version 0.2 - 2016/11/26 41 | 42 | ### Changed 43 | - Cronex is no longer an `Application`, it is now a `Supervisor` defined by `Cronex.Scheduler` 44 | 45 | ### Fixed 46 | - `Cronex.Every.every/3` macro 47 | 48 | ### Improved 49 | - README with a `Getting Started` section 50 | - Overall tests 51 | 52 | ## Version 0.1.1 - 2016/11/05 53 | 54 | ### Fixed 55 | - Adding jobs to the `Cronex.Table` via the `Cronex.Scheduler` 56 | - `Cronex.Table` ping message handling 57 | 58 | ## Version 0.1.0 - 2016/11/02 59 | 60 | ### Added 61 | - Initial version 🎉 62 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 jbernardo95 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Cronex 2 | 3 | [![Travis Build](https://api.travis-ci.org/jbernardo95/cronex.svg?branch=master)](https://travis-ci.org/jbernardo95/cronex/) 4 | 5 | A cron like system, built in Elixir, that you can mount in your supervision tree. 6 | 7 | Cronex's DSL for adding cron jobs is inspired by [whenever](https://github.com/javan/whenever) Ruby gem. 8 | 9 | ## Installation 10 | 11 | Add `cronex` to your list of dependencies in `mix.exs`: 12 | 13 | ```elixir 14 | def deps do 15 | [{:cronex, "~> 0.4.0"}] 16 | end 17 | ``` 18 | 19 | Then run `mix deps.get` to get the package. 20 | 21 | ## Getting started 22 | 23 | Cronex makes it really easy and intuitive to schedule cron like jobs. 24 | 25 | You use the `Cronex.Scheduler` module to define a scheduler and add jobs to it. 26 | 27 | Cronex will gather jobs from the scheduler you defined and will run them at the expected time. 28 | 29 | ```elixir 30 | # Somewhere in your application define your scheduler 31 | defmodule MyApp.Scheduler do 32 | use Cronex.Scheduler 33 | 34 | every :hour do 35 | IO.puts "Every hour job" 36 | end 37 | 38 | every :day, at: "10:00" do 39 | IO.puts "Every day job at 10:00" 40 | end 41 | end 42 | 43 | # Start scheduler with start_link 44 | MyApp.Scheduler.start_link 45 | 46 | # Or add it to your supervision tree 47 | defmodule MyApp.Supervisor do 48 | use Supervisor 49 | 50 | # ... 51 | 52 | def init(_opts) do 53 | children = [ 54 | # ... 55 | supervisor(MyApp.Scheduler, []) 56 | # ... 57 | ] 58 | 59 | supervise(children, ...) 60 | end 61 | 62 | # ... 63 | end 64 | ``` 65 | 66 | You can define as much schedulers as you want. 67 | 68 | ## Testing 69 | 70 | Cronex comes with `Cronex.Test` module which provides helpers to test your cron jobs. 71 | 72 | ```elixir 73 | defmodule MyApp.SchedulerTest do 74 | use ExUnit.Case 75 | use Cronex.Test 76 | 77 | test "every hour job is defined in MyApp.Scheduler" do 78 | assert_job_every :hour, in: MyApp.Scheduler 79 | end 80 | 81 | test "every day job at 10:00 is defined in MyApp.Scheduler" do 82 | assert_job_every :day, at: "10:00", in: MyApp.Scheduler 83 | end 84 | end 85 | ``` 86 | 87 | ## Documentation 88 | 89 | The project documentation can be found [here](https://hexdocs.pm/cronex/api-reference.html). 90 | 91 | ## Contributing 92 | 93 | Bug reports and pull requests are welcome on GitHub at https://github.com/jbernardo95/cronex. 94 | 95 | ## License 96 | 97 | Cronex source code is licensed under the [MIT License](LICENSE.md). 98 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | import_config "#{Mix.env()}.exs" 4 | -------------------------------------------------------------------------------- /config/dev.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | -------------------------------------------------------------------------------- /config/production.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | -------------------------------------------------------------------------------- /lib/cronex.ex: -------------------------------------------------------------------------------- 1 | defmodule Cronex do 2 | @moduledoc """ 3 | This is Cronex main module. 4 | 5 | ## Getting started 6 | 7 | Cronex makes it really easy and intuitive to schedule cron like jobs. 8 | 9 | You use the `Cronex.Scheduler` module to define a scheduler and add jobs to it. 10 | 11 | Cronex will gather jobs from the scheduler you defined and will run them at the expected time. 12 | 13 | # Somewhere in your application define your scheduler 14 | defmodule MyApp.Scheduler do 15 | use Cronex.Scheduler 16 | 17 | every :hour do 18 | IO.puts "Every hour job" 19 | end 20 | 21 | every :day, at: "10:00" do 22 | IO.puts "Every day job at 10:00" 23 | end 24 | end 25 | 26 | # Start scheduler with start_link 27 | MyApp.Scheduler.start_link 28 | 29 | # Or add it to your supervision tree 30 | defmodule MyApp.Supervisor do 31 | use Supervisor 32 | 33 | # ... 34 | 35 | def init(_opts) do 36 | children = [ 37 | # ... 38 | supervisor(MyApp.Scheduler, []) 39 | # ... 40 | ] 41 | 42 | supervise(children, ...) 43 | end 44 | 45 | # ... 46 | end 47 | """ 48 | end 49 | -------------------------------------------------------------------------------- /lib/cronex/every.ex: -------------------------------------------------------------------------------- 1 | defmodule Cronex.Every do 2 | @moduledoc """ 3 | This module defines scheduling macros. 4 | """ 5 | 6 | @doc """ 7 | `Cronex.Every.every/2` macro is used as a simple interface to add a job to the `Cronex.Table`. 8 | 9 | ## Input Arguments 10 | 11 | `frequency` supports the following values: `:minute`, `:hour`, `:day`, `:month`, `:year`, `:monday`, `:tuesday`, `:wednesday`, `:thursday`, `:friday`, `:saturday`, `:sunday` 12 | 13 | `job` must be a list with the following structure: `[do: block]`, where `block` is the code refering to a specific job 14 | 15 | ## Example 16 | 17 | every :day do 18 | # Daily task here 19 | end 20 | 21 | every :month do 22 | # Monthly task here 23 | end 24 | """ 25 | defmacro every(frequency, [do: block] = _job) 26 | when is_atom(frequency) do 27 | job_name = String.to_atom("job_every_#{frequency}") 28 | 29 | quote do 30 | @jobs unquote(job_name) 31 | 32 | @doc false 33 | def unquote(job_name)() do 34 | Cronex.Job.new( 35 | unquote(frequency), 36 | fn -> unquote(block) end 37 | ) 38 | |> Cronex.Job.validate!() 39 | end 40 | end 41 | end 42 | 43 | @doc """ 44 | `Cronex.Every.every/3` macro is used as a simple interface to add a job to the `Cronex.Table`. 45 | 46 | Different argument data types combinations are accepted: 47 | 48 | - When `arg1` is an atom and `arg2` is a string, they represent the `frequency` and `at` respectively. 49 | 50 | - When `arg1` is an integer and `arg2` is a atom, they represent the `interval` and `frequency` respectively. 51 | 52 | ## Input Arguments 53 | 54 | `frequency` supports the following values `:minute`, `:hour`, `:day`, `:month`, `:year`, `:monday`, `:tuesday`, `:wednesday`, `:thursday`, `:friday`, `:saturday`, `:sunday`, when an `interval` is given, only the following values are accepted `:minute`, `:hour`, `:day`, `:month` 55 | 56 | `interval` must be an integer representing the interval of frequencies that should exist between each job run 57 | 58 | `at` must be a list with the following structure: `[at: time]`, where `time` is a string with the following format `HH:MM`, where `HH` represents the hour and `MM` the minutes at which the job should be run, this value is ignored when given in an every minute or every hour job 59 | 60 | `job` must be a list with the following structure: `[do: block]`, where `block` is the code corresponding to a specific job 61 | 62 | ## Example 63 | 64 | every :day, at: "10:00" do 65 | # Daily task at 10:00 here 66 | end 67 | 68 | every :monday, at: "12:00" do 69 | # Monday task at 12:00 here 70 | end 71 | 72 | every 2, :day do 73 | # Every 2 days task 74 | end 75 | 76 | every 3, :week do 77 | # Every 3 weeks task 78 | end 79 | """ 80 | defmacro every(arg1, [at: time] = _arg2, [do: block] = _job) 81 | when is_atom(arg1) and is_bitstring(time) do 82 | job_name = String.to_atom("job_every_#{arg1}_at_#{time}") 83 | 84 | quote do 85 | @jobs unquote(job_name) 86 | 87 | @doc false 88 | def unquote(job_name)() do 89 | Cronex.Job.new( 90 | unquote(arg1), 91 | unquote(time), 92 | fn -> unquote(block) end 93 | ) 94 | |> Cronex.Job.validate!() 95 | end 96 | end 97 | end 98 | 99 | defmacro every(arg1, arg2, [do: block] = _job) 100 | when is_integer(arg1) and is_atom(arg2) do 101 | job_name = String.to_atom("job_every_#{arg1}_#{arg2}") 102 | 103 | quote do 104 | @jobs unquote(job_name) 105 | 106 | @doc false 107 | def unquote(job_name)() do 108 | Cronex.Job.new( 109 | unquote(arg1), 110 | unquote(arg2), 111 | fn -> unquote(block) end 112 | ) 113 | |> Cronex.Job.validate!() 114 | end 115 | end 116 | end 117 | 118 | @doc """ 119 | `Cronex.Every.every/4` macro is used as a simple interface to add a job to the `Cronex.Table`. 120 | 121 | ## Input Arguments 122 | 123 | `interval` must be an integer representing the interval of frequencies that should exist between each job run 124 | 125 | `frequency` supports the following values: `:minute`, `:hour`, `:day`, `:month` 126 | 127 | `at` must be a list with the following structure: `[at: time]`, where `time` is a string with the following format `HH:MM`, where `HH` represents the hour and `MM` the minutes at which the job should be run, this value is ignored when given in an every minute or every hour job 128 | 129 | `job` must be a list with the following structure: `[do: block]`, where `block` is the code corresponding to a specific job 130 | 131 | ## Example 132 | 133 | every 2, :day, at: "10:00" do 134 | # Every 2 days task 135 | end 136 | 137 | every 3, :week, at: "10:00" do 138 | # Every 3 weeks task 139 | end 140 | """ 141 | defmacro every(interval, frequency, [at: time] = _at, [do: block] = _job) 142 | when is_integer(interval) and is_atom(frequency) do 143 | job_name = String.to_atom("job_every_#{interval}_#{frequency}_at_#{time}") 144 | 145 | quote do 146 | @jobs unquote(job_name) 147 | 148 | @doc false 149 | def unquote(job_name)() do 150 | Cronex.Job.new( 151 | unquote(interval), 152 | unquote(frequency), 153 | unquote(time), 154 | fn -> unquote(block) end 155 | ) 156 | |> Cronex.Job.validate!() 157 | end 158 | end 159 | end 160 | end 161 | -------------------------------------------------------------------------------- /lib/cronex/job.ex: -------------------------------------------------------------------------------- 1 | defmodule Cronex.Job do 2 | @moduledoc """ 3 | This module represents a job. 4 | """ 5 | 6 | import Cronex.Parser 7 | 8 | defstruct frequency: nil, 9 | task: nil, 10 | pid: nil 11 | 12 | @doc """ 13 | Creates a `%Job{}` with a given frequency and task. 14 | 15 | Check `Cronex.Every.every/3` documentation, to view the accepted `frequency` arguments. 16 | """ 17 | def new(frequency, task) 18 | when is_atom(frequency) and is_function(task) do 19 | %Cronex.Job{} 20 | |> Map.put(:frequency, parse_regular_frequency(frequency)) 21 | |> Map.put(:task, task) 22 | end 23 | 24 | @doc """ 25 | Creates a `%Job{}` with the given arguments. 26 | 27 | Different argument data types combinations are accepted: 28 | 29 | - When `arg1` is an atom and `arg2` is a string, they represent the `frequency` and `time` respectively. 30 | 31 | - When `arg1` is an integer and `arg2` is an atom, they represent the `interval` and `frequency` respectively. 32 | 33 | Check `Cronex.Every.every/3` documentation, to view the accepted `frequency` and `time` arguments. 34 | """ 35 | def new(arg1, arg2, task) 36 | when is_atom(arg1) and is_bitstring(arg2) and is_function(task) do 37 | %Cronex.Job{} 38 | |> Map.put(:frequency, parse_regular_frequency(arg1, arg2)) 39 | |> Map.put(:task, task) 40 | end 41 | 42 | def new(arg1, arg2, task) 43 | when is_integer(arg1) and is_atom(arg2) and is_function(task) do 44 | %Cronex.Job{} 45 | |> Map.put(:frequency, parse_interval_frequency(arg1, arg2)) 46 | |> Map.put(:task, task) 47 | end 48 | 49 | @doc """ 50 | Creates a `%Job{}` with the given interval, frequency, time and task. 51 | 52 | Check `Cronex.Every.every/4` documentation, to view the accepted `interval`, `frequency` and `time` arguments. 53 | """ 54 | def new(interval, frequency, time, task) 55 | when is_integer(interval) and is_atom(frequency) and is_function(task) do 56 | %Cronex.Job{} 57 | |> Map.put(:frequency, parse_interval_frequency(interval, frequency, time)) 58 | |> Map.put(:task, task) 59 | end 60 | 61 | @doc """ 62 | Validates a given `%Job{}`. 63 | 64 | Returns the given %Job{} if the job is valid, raises an error if the job is invalid. 65 | """ 66 | def validate!(%Cronex.Job{frequency: frequency} = job) do 67 | case frequency do 68 | :invalid -> raise_invalid_frequency_error() 69 | _ -> job 70 | end 71 | end 72 | 73 | @doc """ 74 | Runs and updates the pid attribute of a given `%Job{}`. 75 | """ 76 | def run(%Cronex.Job{task: task} = job, supervisor) do 77 | {:ok, pid} = Task.Supervisor.start_child(supervisor, task) 78 | job |> Map.put(:pid, pid) 79 | end 80 | 81 | @doc """ 82 | Checks if a given `%Job{}` can run, based on it's frequency and pid. 83 | """ 84 | def can_run?(%Cronex.Job{} = job) do 85 | # TODO Process.alive? only works for local processes, improve this to support several nodes 86 | 87 | # Is time to run 88 | # Job process is dead or non existing 89 | is_time(job.frequency) and (job.pid == nil or !Process.alive?(job.pid)) 90 | end 91 | 92 | defp raise_invalid_frequency_error do 93 | raise ArgumentError, """ 94 | An invalid frequency was given when creating a job. 95 | 96 | Check the docs to see the accepted frequency arguments. 97 | """ 98 | end 99 | 100 | # Every minute job 101 | defp is_time({:*, :*, :*, :*, :*}), do: true 102 | 103 | # Every interval minute job, check interval minute 104 | defp is_time({interval, :*, :*, :*, :*}) when is_function(interval) do 105 | interval.(current_date_time().minute) == 0 106 | end 107 | 108 | # Every hour job, check minute of job 109 | defp is_time({minute, :*, :*, :*, :*}) 110 | when is_integer(minute) do 111 | current_date_time().minute == minute 112 | end 113 | 114 | # Every interval hour job, check minute of job and interval hour 115 | defp is_time({minute, interval, :*, :*, :*}) 116 | when is_integer(minute) and is_function(interval) do 117 | current_date_time().minute == minute and interval.(current_date_time().hour) == 0 118 | end 119 | 120 | # Every day job, check time of job 121 | defp is_time({minute, hour, :*, :*, :*}) 122 | when is_integer(minute) and is_integer(hour) do 123 | current_date_time().minute == minute and current_date_time().hour == hour 124 | end 125 | 126 | # Every interval day job, check time of job and interval day 127 | defp is_time({minute, hour, interval, :*, :*}) 128 | when is_integer(minute) and is_integer(hour) and is_function(interval) do 129 | current_date_time().minute == minute and current_date_time().hour == hour and 130 | interval.(current_date_time().day - 1) == 0 131 | end 132 | 133 | # Every week job, check time and day of the week 134 | defp is_time({minute, hour, :*, :*, day_of_week}) do 135 | current_date_time().minute == minute and current_date_time().hour == hour and 136 | Date.day_of_week(current_date_time()) == day_of_week 137 | end 138 | 139 | # Every month job, check time and day of job 140 | defp is_time({minute, hour, day, :*, :*}) 141 | when is_integer(minute) and is_integer(hour) and is_integer(day) do 142 | current_date_time().minute == minute and current_date_time().hour == hour and 143 | current_date_time().day == day 144 | end 145 | 146 | # Every interval month job, check time, day and interval month 147 | defp is_time({minute, hour, day, interval, :*}) 148 | when is_integer(minute) and is_integer(hour) and is_integer(day) and is_function(interval) do 149 | current_date_time().minute == minute and current_date_time().hour == hour and 150 | current_date_time().day == day and interval.(current_date_time().month - 1) == 0 151 | end 152 | 153 | # Every year job, check month, day and time of job 154 | defp is_time({minute, hour, day, month, :*}) do 155 | current_date_time().minute == minute and current_date_time().hour == hour and 156 | current_date_time().day == day and current_date_time().month == month 157 | end 158 | 159 | defp is_time(_frequency), do: false 160 | 161 | defp current_date_time do 162 | date_time_provider = Application.get_env(:cronex, :date_time_provider, DateTime) 163 | date_time_provider.utc_now 164 | end 165 | end 166 | -------------------------------------------------------------------------------- /lib/cronex/parser.ex: -------------------------------------------------------------------------------- 1 | defmodule Cronex.Parser do 2 | @moduledoc """ 3 | This modules is responsible for time parsing. 4 | """ 5 | 6 | @days_of_week [:monday, :tuesday, :wednesday, :thursday, :friday, :saturday, :sunday] 7 | 8 | @doc """ 9 | Parses a given `frequency` and `time` to a tuple. 10 | 11 | ## Example 12 | 13 | iex> Cronex.Parser.parse_regular_frequency(:hour) 14 | {0, :*, :*, :*, :*} 15 | 16 | iex> Cronex.Parser.parse_regular_frequency(:day, "10:00") 17 | {0, 10, :*, :*, :*} 18 | 19 | iex> Cronex.Parser.parse_regular_frequency(:day, "12:10") 20 | {10, 12, :*, :*, :*} 21 | 22 | iex> Cronex.Parser.parse_regular_frequency(:wednesday, "12:00") 23 | {0, 12, :*, :*, 3} 24 | 25 | iex> Cronex.Parser.parse_regular_frequency(:non_existing_day) 26 | :invalid 27 | 28 | iex> Cronex.Parser.parse_regular_frequency(:monday, "invalid time") 29 | :invalid 30 | """ 31 | def parse_regular_frequency(frequency, time \\ "00:00") do 32 | parsed_time = parse_time(time) 33 | do_parse_regular_frequency(frequency, parsed_time) 34 | end 35 | 36 | defp do_parse_regular_frequency(_, :invalid), do: :invalid 37 | 38 | defp do_parse_regular_frequency(frequency, {hour, minute}) do 39 | cond do 40 | frequency == :minute -> 41 | {:*, :*, :*, :*, :*} 42 | 43 | frequency == :hour -> 44 | {0, :*, :*, :*, :*} 45 | 46 | frequency == :day -> 47 | {minute, hour, :*, :*, :*} 48 | 49 | frequency == :month -> 50 | {minute, hour, 1, :*, :*} 51 | 52 | frequency == :year -> 53 | {minute, hour, 1, 1, :*} 54 | 55 | frequency in @days_of_week -> 56 | day_of_week = Enum.find_index(@days_of_week, &(&1 == frequency)) + 1 57 | {minute, hour, :*, :*, day_of_week} 58 | 59 | true -> 60 | :invalid 61 | end 62 | end 63 | 64 | @doc """ 65 | Parses a given `interval`, `frequency` and `time` to a tuple. 66 | 67 | `interval` is a function wich receives one argument and returns the remainder of the division of that argument by the given `interval` 68 | 69 | ## Example 70 | 71 | iex> Cronex.Parser.parse_interval_frequency(2, :hour) 72 | {0, interval, :*, :*, :*} 73 | 74 | iex> Cronex.Parser.parse_interval_frequency(2, :invalid_day) 75 | :invalid 76 | """ 77 | def parse_interval_frequency(interval, frequency, time \\ "00:00") do 78 | parsed_time = parse_time(time) 79 | do_parse_interval_frequency(interval, frequency, parsed_time) 80 | end 81 | 82 | defp do_parse_interval_frequency(_, _, :invalid), do: :invalid 83 | 84 | defp do_parse_interval_frequency(interval, frequency, {hour, minute}) do 85 | interval_fn = fn arg -> rem(arg, interval) end 86 | 87 | cond do 88 | frequency == :minute -> 89 | {interval_fn, :*, :*, :*, :*} 90 | 91 | frequency == :hour -> 92 | {0, interval_fn, :*, :*, :*} 93 | 94 | frequency == :day -> 95 | {minute, hour, interval_fn, :*, :*} 96 | 97 | frequency == :month -> 98 | {minute, hour, 1, interval_fn, :*} 99 | 100 | true -> 101 | :invalid 102 | end 103 | end 104 | 105 | defp parse_time(time) when is_bitstring(time) do 106 | try do 107 | time 108 | |> String.split(":") 109 | |> Enum.map(&String.to_integer/1) 110 | |> List.to_tuple() 111 | rescue 112 | _ -> :invalid 113 | end 114 | end 115 | end 116 | -------------------------------------------------------------------------------- /lib/cronex/scheduler.ex: -------------------------------------------------------------------------------- 1 | defmodule Cronex.Scheduler do 2 | @moduledoc """ 3 | This module implements a scheduler. 4 | 5 | It is responsible for scheduling jobs. 6 | """ 7 | 8 | defmacro __using__(_opts) do 9 | quote do 10 | use Supervisor 11 | 12 | import Cronex.Every 13 | 14 | Module.register_attribute(__MODULE__, :jobs, accumulate: true) 15 | 16 | @before_compile unquote(__MODULE__) 17 | 18 | def start_link do 19 | Supervisor.start_link(__MODULE__, nil, name: __MODULE__) 20 | end 21 | 22 | @doc false 23 | def init(_opts) do 24 | children = [ 25 | supervisor(Task.Supervisor, [[name: job_supervisor()]]), 26 | worker(Cronex.Table, [[scheduler: __MODULE__], [name: table()]]) 27 | ] 28 | 29 | Supervisor.init(children, strategy: :one_for_one) 30 | end 31 | 32 | @doc false 33 | def job_supervisor, do: :"#{__MODULE__}.JobSupervisor" 34 | 35 | @doc false 36 | def table, do: :"#{__MODULE__}.Table" 37 | end 38 | end 39 | 40 | defmacro __before_compile__(_opts) do 41 | quote do 42 | def jobs do 43 | @jobs 44 | end 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/cronex/table.ex: -------------------------------------------------------------------------------- 1 | defmodule Cronex.Table do 2 | @moduledoc """ 3 | This module represents a cron table. 4 | """ 5 | 6 | use GenServer 7 | 8 | import Cronex.Job 9 | 10 | alias Cronex.Job 11 | 12 | # Interface functions 13 | @doc """ 14 | Starts a `Cronex.Table` instance. 15 | 16 | `args` must contain a `:scheduler` with a valid `Cronex.Scheduler`. 17 | """ 18 | def start_link(args, opts \\ []) do 19 | GenServer.start_link(__MODULE__, args, opts) 20 | end 21 | 22 | @doc false 23 | def add_job(pid, %Job{} = job) do 24 | GenServer.call(pid, {:add_job, job}) 25 | end 26 | 27 | @doc false 28 | def get_jobs(pid) do 29 | GenServer.call(pid, :get_jobs) 30 | end 31 | 32 | # Callback functions 33 | def init(args) do 34 | scheduler = args[:scheduler] 35 | 36 | if is_nil(scheduler), do: raise_scheduler_not_provided_error() 37 | 38 | GenServer.cast(self(), :init) 39 | 40 | state = %{ 41 | scheduler: scheduler, 42 | jobs: Map.new(), 43 | timer: new_ping_timer(), 44 | leader: false 45 | } 46 | 47 | {:ok, state} 48 | end 49 | 50 | def handle_cast(:init, %{scheduler: scheduler} = state) do 51 | # Load jobs 52 | new_state = 53 | scheduler.jobs 54 | |> Enum.reduce(state, fn job, state -> 55 | job = apply(scheduler, job, []) 56 | do_add_job(state, job) 57 | end) 58 | 59 | # Try to become leader 60 | new_state = try_become_leader(new_state) 61 | 62 | {:noreply, new_state} 63 | end 64 | 65 | def handle_call({:add_job, %Job{} = job}, _from, state) do 66 | new_state = state |> do_add_job(job) 67 | {:reply, :ok, new_state} 68 | end 69 | 70 | def handle_call(:get_jobs, _from, state) do 71 | {:reply, state[:jobs], state} 72 | end 73 | 74 | def handle_call(:new_leader, _from, state) do 75 | {:reply, :ok, Map.put(state, :leader, false)} 76 | end 77 | 78 | def handle_info(:ping, %{leader: false} = state), do: {:noreply, state} 79 | 80 | def handle_info(:ping, %{scheduler: scheduler} = state) do 81 | updated_timer = new_ping_timer() 82 | 83 | updated_jobs = 84 | for {id, job} <- state[:jobs], into: %{} do 85 | updated_job = 86 | if job |> can_run? do 87 | job |> run(scheduler.job_supervisor) 88 | else 89 | job 90 | end 91 | 92 | {id, updated_job} 93 | end 94 | 95 | new_state = %{state | timer: updated_timer, jobs: updated_jobs} 96 | {:noreply, new_state} 97 | end 98 | 99 | defp raise_scheduler_not_provided_error do 100 | raise ArgumentError, 101 | message: """ 102 | No scheduler was provided when starting Cronex.Table. 103 | 104 | Please provide a Scheduler like so: 105 | 106 | Cronex.Table.start_link(scheduler: MyApp.Scheduler) 107 | """ 108 | end 109 | 110 | defp try_become_leader(%{scheduler: scheduler} = state) do 111 | trans_result = 112 | :global.trans( 113 | {:leader, self()}, 114 | fn -> 115 | case GenServer.multi_call(Node.list(), scheduler.table, :new_leader) do 116 | {_, []} -> :ok 117 | _ -> :aborted 118 | end 119 | end, 120 | Node.list([:this, :visible]), 121 | 0 122 | ) 123 | 124 | case trans_result do 125 | :ok -> Map.put(state, :leader, true) 126 | :aborted -> Map.put(state, :leader, false) 127 | end 128 | end 129 | 130 | defp do_add_job(state, %Job{} = job) do 131 | index = state[:jobs] |> Map.keys() |> Enum.count() 132 | put_in(state, [:jobs, index], job) 133 | end 134 | 135 | defp new_ping_timer, do: Process.send_after(self(), :ping, ping_interval()) 136 | 137 | defp ping_interval, do: Application.get_env(:cronex, :ping_interval, 30000) 138 | end 139 | -------------------------------------------------------------------------------- /lib/cronex/test.ex: -------------------------------------------------------------------------------- 1 | defmodule Cronex.Test do 2 | @moduledoc """ 3 | This module defines helpers for testing cron jobs definition. 4 | """ 5 | 6 | alias Cronex.Job 7 | alias Cronex.Table 8 | alias Cronex.Parser 9 | 10 | defmacro __using__(_opts) do 11 | quote do 12 | import Cronex.Test 13 | end 14 | end 15 | 16 | @doc """ 17 | Checks if a job with the specified `frequency` and `time` is defined inside the given `scheduler`. 18 | 19 | `time` (optional) and `scheduler` should be passed inside `opts` as a keyword list. 20 | 21 | You can also specify how many jobs with the given `frequency` and `time` should be defined using the `count` parameter inside the `opts` list. It defaults to 1. 22 | 23 | ## Example 24 | 25 | # Asserts if an every hour job is defined in MyApp.Scheduler 26 | assert_job_every :hour, in: MyApp.Scheduler 27 | 28 | # Asserts if an every day job at 10:00 is defined in MyApp.Scheduler 29 | assert_job_every :day, at: "10:00", in: MyApp.Scheduler 30 | 31 | # Asserts if 3 every day jobs at 10:00 are defined in MyApp.Scheduler 32 | assert_job_every :day, at: "10:00", in: MyApp.Scheduler, count: 3 33 | """ 34 | defmacro assert_job_every(frequency, opts) 35 | when is_atom(frequency) do 36 | time = Keyword.get(opts, :at, "00:00") 37 | scheduler = Keyword.get(opts, :in) 38 | count = Keyword.get(opts, :count, 1) 39 | 40 | quote bind_quoted: [scheduler: scheduler, frequency: frequency, time: time, count: count] do 41 | assert count == Cronex.Test.find_jobs_by_frequency(scheduler.table, frequency, time) 42 | end 43 | end 44 | 45 | @doc false 46 | def find_jobs_by_frequency(table, frequency, time) do 47 | table 48 | |> Table.get_jobs() 49 | |> Map.values() 50 | |> Enum.count(fn %Job{frequency: job_frequency} -> 51 | job_frequency == Parser.parse_regular_frequency(frequency, time) 52 | end) 53 | end 54 | 55 | @doc """ 56 | Checks if a job with the specified `interval`, `frequency` and `time` is defined inside the given `scheduler`. 57 | 58 | `time` (optional) and `scheduler` should be passed inside `opts` as a keyword list. 59 | 60 | You can also specify how many jobs with the given `frequency` and `time` should be defined using the `count` parameter inside the `opts` list. It defaults to 1. 61 | 62 | ## Example 63 | 64 | # Asserts if an every hour job is defined in MyApp.Scheduler 65 | assert_job_every 2, :hour, in: MyApp.Scheduler 66 | 67 | # Asserts if an every day job at 10:00 is defined in MyApp.Scheduler 68 | assert_job_every 3, :day, at: "10:00", in: MyApp.Scheduler 69 | 70 | # Asserts if 3 every day jobs at 10:00 are defined in MyApp.Scheduler 71 | assert_job_every 3, :day, at: "10:00", in: MyApp.Scheduler, count: 3 72 | """ 73 | defmacro assert_job_every(interval, frequency, opts) 74 | when is_integer(interval) and is_atom(frequency) do 75 | time = Keyword.get(opts, :at, "00:00") 76 | scheduler = Keyword.get(opts, :in) 77 | count = Keyword.get(opts, :count, 1) 78 | 79 | quote bind_quoted: [ 80 | scheduler: scheduler, 81 | interval: interval, 82 | frequency: frequency, 83 | time: time, 84 | count: count 85 | ] do 86 | assert count == 87 | Cronex.Test.find_jobs_by_frequency(scheduler.table, interval, frequency, time) 88 | end 89 | end 90 | 91 | @doc false 92 | def find_jobs_by_frequency(table, interval, frequency, time) do 93 | table 94 | |> Table.get_jobs() 95 | |> Map.values() 96 | |> Enum.count(fn %Job{frequency: job_frequency} -> 97 | job_frequency == Parser.parse_interval_frequency(interval, frequency, time) 98 | end) 99 | end 100 | end 101 | -------------------------------------------------------------------------------- /lib/cronex/test/date_time.ex: -------------------------------------------------------------------------------- 1 | defmodule Cronex.Test.DateTime do 2 | @moduledoc """ 3 | Simple DateTime provider that is static and user manipulated. 4 | """ 5 | 6 | def start_link do 7 | Agent.start_link(fn -> DateTime.from_unix!(0) end, name: __MODULE__) 8 | end 9 | 10 | @doc """ 11 | Sets the DateTime value of the provider. 12 | """ 13 | def set(args) when is_list(args) do 14 | args_map = Enum.into(args, Map.new()) 15 | Agent.update(__MODULE__, fn date_time -> Map.merge(date_time, args_map) end) 16 | end 17 | 18 | @doc """ 19 | Gets the current DateTime value of the provider. 20 | """ 21 | def get do 22 | Agent.get(__MODULE__, & &1) 23 | end 24 | 25 | @doc """ 26 | An alias for the `get/0` function, to mimic the `DateTime` module behaviour. 27 | """ 28 | def utc_now, do: get() 29 | end 30 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Cronex.Mixfile do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :cronex, 7 | version: "0.4.0", 8 | elixir: "~> 1.7", 9 | build_embedded: Mix.env() == :prod, 10 | start_permanent: Mix.env() == :prod, 11 | deps: deps(), 12 | package: package(), 13 | name: "Cronex", 14 | description: 15 | "A cron like system built in Elixir, that you can mount in your supervision tree", 16 | source_url: "https://github.com/jbernardo95/cronex", 17 | homepage_url: "https://github.com/jbernardo95/cronex", 18 | docs: [main: "readme", extras: ["README.md", "CHANGELOG.md"]] 19 | ] 20 | end 21 | 22 | def application do 23 | [applications: [:logger]] 24 | end 25 | 26 | defp deps do 27 | [{:ex_doc, "~> 0.14", only: :dev}] 28 | end 29 | 30 | def package do 31 | [ 32 | maintainers: ["jbernardo95"], 33 | licenses: ["MIT"], 34 | links: %{"GitHub" => "https://github.com/jbernardo95/cronex"} 35 | ] 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{"earmark": {:hex, :earmark, "1.0.3", "89bdbaf2aca8bbb5c97d8b3b55c5dd0cff517ecc78d417e87f1d0982e514557b", [:mix], []}, 2 | "ex_doc": {:hex, :ex_doc, "0.14.3", "e61cec6cf9731d7d23d254266ab06ac1decbb7651c3d1568402ec535d387b6f7", [:mix], [{:earmark, "~> 1.0", [hex: :earmark, optional: false]}]}} 3 | -------------------------------------------------------------------------------- /test/cronex/job_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Cronex.JobTest do 2 | use ExUnit.Case 3 | 4 | alias Cronex.Job 5 | alias Cronex.Test 6 | 7 | test "new/2 returns a %Job{}" do 8 | task = fn -> :ok end 9 | job = Job.new(:day, task) 10 | 11 | assert job == %Job{frequency: {0, 0, :*, :*, :*}, task: task} 12 | end 13 | 14 | test "new/3 regular returns a %Job{}" do 15 | task = fn -> :ok end 16 | job = Job.new(:day, "10:00", task) 17 | 18 | assert job == %Job{frequency: {0, 10, :*, :*, :*}, task: task} 19 | end 20 | 21 | test "new/3 interval returns a %Job{}" do 22 | task = fn -> :ok end 23 | job = Job.new(2, :hour, task) 24 | 25 | assert %Job{frequency: {0, interval, :*, :*, :*}, task: t} = job 26 | assert t == task 27 | assert is_function(interval) 28 | end 29 | 30 | test "new/4 returns a %Job{}" do 31 | task = fn -> :ok end 32 | job = Job.new(2, :day, "10:30", task) 33 | 34 | assert %Job{frequency: {30, 10, interval, :*, :*}, task: t} = job 35 | assert t == task 36 | assert is_function(interval) 37 | end 38 | 39 | describe "validate!/1" do 40 | test "returns the given job if the job is valid" do 41 | task = fn -> :ok end 42 | job = Job.new(:day, "10:00", task) 43 | 44 | assert job == Job.validate!(job) 45 | end 46 | 47 | test "raises invalid frequency error when a job with an invalid frequency is given" do 48 | task = fn -> :ok end 49 | job = Job.new(:invalid_frequency, task) 50 | 51 | assert_raise ArgumentError, fn -> 52 | Job.validate!(job) 53 | end 54 | end 55 | end 56 | 57 | test "run/1 returns updated %Job{}" do 58 | {:ok, job_supervisor} = Task.Supervisor.start_link() 59 | 60 | task = fn -> :ok end 61 | job = Job.new(:day, task) 62 | 63 | assert job.pid == nil 64 | %Job{pid: pid} = Job.run(job, job_supervisor) 65 | assert pid != nil 66 | end 67 | 68 | test "can_run?/1 with an every minute job returns true" do 69 | task = fn -> :ok end 70 | job = Job.new(:minute, task) 71 | 72 | assert true == Cronex.Job.can_run?(job) 73 | end 74 | 75 | test "can_run?/1 with an every interval minute job" do 76 | task = fn -> :ok end 77 | job = Job.new(2, :minute, task) 78 | 79 | Test.DateTime.set(minute: 0) 80 | assert true == Cronex.Job.can_run?(job) 81 | 82 | Test.DateTime.set(minute: 1) 83 | assert false == Cronex.Job.can_run?(job) 84 | 85 | Test.DateTime.set(minute: 2) 86 | assert true == Cronex.Job.can_run?(job) 87 | end 88 | 89 | test "can_run?/1 with an every hour job" do 90 | task = fn -> :ok end 91 | job = Job.new(:hour, task) 92 | 93 | Test.DateTime.set(minute: 0) 94 | assert true == Cronex.Job.can_run?(job) 95 | 96 | Test.DateTime.set(minute: 1) 97 | assert false == Cronex.Job.can_run?(job) 98 | end 99 | 100 | test "can_run?/1 with an every interval hour job" do 101 | task = fn -> :ok end 102 | job = Job.new(2, :hour, task) 103 | 104 | Test.DateTime.set(hour: 0, minute: 0) 105 | assert true == Cronex.Job.can_run?(job) 106 | 107 | Test.DateTime.set(hour: 1) 108 | assert false == Cronex.Job.can_run?(job) 109 | 110 | Test.DateTime.set(hour: 2) 111 | assert true == Cronex.Job.can_run?(job) 112 | end 113 | 114 | test "can_run?/1 with an every day job" do 115 | task = fn -> :ok end 116 | job = Job.new(:day, task) 117 | 118 | Test.DateTime.set(hour: 0, minute: 0) 119 | assert true == Cronex.Job.can_run?(job) 120 | 121 | Test.DateTime.set(hour: 0, minute: 1) 122 | assert false == Cronex.Job.can_run?(job) 123 | 124 | Test.DateTime.set(hour: 1, minute: 0) 125 | assert false == Cronex.Job.can_run?(job) 126 | 127 | Test.DateTime.set(hour: 1, minute: 1) 128 | assert false == Cronex.Job.can_run?(job) 129 | end 130 | 131 | test "can_run?/1 with an every interval day job" do 132 | task = fn -> :ok end 133 | job = Job.new(2, :day, task) 134 | 135 | Test.DateTime.set(day: 1, hour: 0, minute: 0) 136 | assert true == Cronex.Job.can_run?(job) 137 | 138 | Test.DateTime.set(day: 2) 139 | assert false == Cronex.Job.can_run?(job) 140 | 141 | Test.DateTime.set(day: 3) 142 | assert true == Cronex.Job.can_run?(job) 143 | end 144 | 145 | test "can_run?/1 with an every week day job" do 146 | task = fn -> :ok end 147 | job = Job.new(:wednesday, task) 148 | 149 | # day_of_week == 3 150 | Test.DateTime.set(year: 2017, month: 1, day: 4, hour: 0, minute: 0) 151 | assert true == Cronex.Job.can_run?(job) 152 | 153 | # day_of_week == 1 154 | Test.DateTime.set(year: 2017, month: 1, day: 2, hour: 0, minute: 0) 155 | assert false == Cronex.Job.can_run?(job) 156 | 157 | # day_of_week == 3 158 | Test.DateTime.set(year: 2017, month: 1, day: 4, hour: 1, minute: 0) 159 | assert false == Cronex.Job.can_run?(job) 160 | end 161 | 162 | test "can_run?/1 with an every month job" do 163 | task = fn -> :ok end 164 | job = Job.new(:month, task) 165 | 166 | Test.DateTime.set(day: 1, hour: 0, minute: 0) 167 | assert true == Cronex.Job.can_run?(job) 168 | 169 | Test.DateTime.set(day: 2, hour: 0, minute: 0) 170 | assert false == Cronex.Job.can_run?(job) 171 | 172 | Test.DateTime.set(day: 1, hour: 1, minute: 0) 173 | assert false == Cronex.Job.can_run?(job) 174 | end 175 | 176 | test "can_run?/1 with an every interval month job" do 177 | task = fn -> :ok end 178 | job = Job.new(2, :month, task) 179 | 180 | Test.DateTime.set(month: 1, day: 1, hour: 0, minute: 0) 181 | assert true == Cronex.Job.can_run?(job) 182 | 183 | Test.DateTime.set(month: 2) 184 | assert false == Cronex.Job.can_run?(job) 185 | 186 | Test.DateTime.set(month: 3) 187 | assert true == Cronex.Job.can_run?(job) 188 | end 189 | 190 | test "can_run?/1 with an every year job" do 191 | task = fn -> :ok end 192 | job = Job.new(:year, task) 193 | 194 | Test.DateTime.set(month: 1, day: 1, hour: 0, minute: 0) 195 | assert true == Cronex.Job.can_run?(job) 196 | 197 | Test.DateTime.set(month: 2, day: 1, hour: 0, minute: 0) 198 | assert false == Cronex.Job.can_run?(job) 199 | 200 | Test.DateTime.set(month: 1, day: 2, hour: 0, minute: 0) 201 | assert false == Cronex.Job.can_run?(job) 202 | end 203 | end 204 | -------------------------------------------------------------------------------- /test/cronex/parser_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Cronex.ParserTest do 2 | use ExUnit.Case 3 | doctest Cronex.Parser, except: [parse_interval_frequency: 3] 4 | 5 | test "parse_interval_frequency/2" do 6 | assert {0, interval_fn, :*, :*, :*} = Cronex.Parser.parse_interval_frequency(2, :hour) 7 | assert 0 == interval_fn.(0) 8 | assert 0 == interval_fn.(2) 9 | assert 0 == interval_fn.(4) 10 | assert 1 == interval_fn.(3) 11 | end 12 | 13 | test "parse_interval_frequency/3" do 14 | assert {10, 12, interval_fn, :*, :*} = 15 | Cronex.Parser.parse_interval_frequency(4, :day, "12:10") 16 | 17 | assert 0 == interval_fn.(0) 18 | assert 0 == interval_fn.(4) 19 | assert 0 == interval_fn.(8) 20 | assert 2 == interval_fn.(2) 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /test/cronex/scheduler_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Cronex.SchedulerTest do 2 | use ExUnit.Case 3 | use Cronex.Test 4 | 5 | alias Cronex.Job 6 | alias Cronex.Test 7 | 8 | @timeout 150 9 | 10 | defmodule TestScheduler do 11 | use Cronex.Scheduler 12 | 13 | every :hour do 14 | send(test_process(), {:ok, :every_hour}) 15 | end 16 | 17 | every :day, at: "10:00" do 18 | send(test_process(), {:ok, :every_day}) 19 | end 20 | 21 | every :friday, at: "12:00" do 22 | send(test_process(), {:ok, :every_friday}) 23 | end 24 | 25 | every 2, :hour do 26 | send(test_process(), {:ok, :every_2_hour}) 27 | end 28 | 29 | every 3, :day, at: "15:30" do 30 | send(test_process(), {:ok, :every_3_day}) 31 | end 32 | 33 | defp test_process do 34 | Application.get_env(:cronex, :test_process) 35 | end 36 | end 37 | 38 | setup_all do 39 | {:ok, _} = TestScheduler.start_link() 40 | :ok 41 | end 42 | 43 | setup do 44 | Application.put_env(:cronex, :test_process, self()) 45 | end 46 | 47 | test "loads jobs from TestScheduler" do 48 | assert %{0 => %Job{}, 1 => %Job{}, 2 => %Job{}} = Cronex.Table.get_jobs(TestScheduler.table()) 49 | assert 5 == Cronex.Table.get_jobs(TestScheduler.table()) |> map_size 50 | end 51 | 52 | test "TestScheduler starts table and task supervisor" do 53 | assert %{active: 2, specs: 2, supervisors: 1, workers: 1} == 54 | Supervisor.count_children(__MODULE__.TestScheduler) 55 | end 56 | 57 | test "every hour job runs on the expected time" do 58 | Test.DateTime.set(minute: 0) 59 | assert_receive {:ok, :every_hour}, @timeout 60 | 61 | Test.DateTime.set(minute: 1) 62 | refute_receive {:ok, :every_hour}, @timeout 63 | end 64 | 65 | test "every day job runs on the expected time" do 66 | Test.DateTime.set(hour: 10, minute: 0) 67 | assert_receive {:ok, :every_day}, @timeout 68 | 69 | Test.DateTime.set(hour: 11) 70 | refute_receive {:ok, :every_day}, @timeout 71 | end 72 | 73 | test "every friday job runs on the expected time" do 74 | # day_of_week == 5 75 | Test.DateTime.set(year: 2017, month: 1, day: 6, hour: 12, minute: 0) 76 | assert_receive {:ok, :every_friday}, @timeout 77 | 78 | Test.DateTime.set(hour: 13) 79 | refute_receive {:ok, :every_friday}, @timeout 80 | end 81 | 82 | test "every 2 hour job runs on the expected time" do 83 | Test.DateTime.set(hour: 0, minute: 0) 84 | assert_receive {:ok, :every_2_hour}, @timeout 85 | 86 | Test.DateTime.set(hour: 1) 87 | refute_receive {:ok, :every_2_hour}, @timeout 88 | 89 | Test.DateTime.set(hour: 2) 90 | assert_receive {:ok, :every_2_hour}, @timeout 91 | end 92 | 93 | test "every 3 day job runs on the expected time" do 94 | Test.DateTime.set(day: 1, hour: 15, minute: 30) 95 | assert_receive {:ok, :every_3_day}, @timeout 96 | 97 | Test.DateTime.set(day: 2) 98 | refute_receive {:ok, :every_3_day}, @timeout 99 | 100 | Test.DateTime.set(day: 4) 101 | assert_receive {:ok, :every_3_day}, @timeout 102 | 103 | Test.DateTime.set(hour: 16) 104 | refute_receive {:ok, :every_3_day}, @timeout 105 | end 106 | 107 | test "every hour job is defined inside TestScheduler" do 108 | assert_job_every(:hour, in: TestScheduler) 109 | end 110 | 111 | test "every day job at 10:00 is defined inside TestScheduler" do 112 | assert_job_every(:day, at: "10:00", in: TestScheduler) 113 | end 114 | 115 | test "every friday job at 12:00 is defined inside TestScheduler" do 116 | assert_job_every(:day, at: "10:00", in: TestScheduler) 117 | end 118 | 119 | test "every 2 hour job is defined inside TestScheduler" do 120 | assert_job_every(2, :hour, in: TestScheduler) 121 | end 122 | 123 | test "every 3 day job at 15:30 is defined inside TestScheduler" do 124 | assert_job_every(3, :day, at: "15:30", in: TestScheduler) 125 | end 126 | end 127 | -------------------------------------------------------------------------------- /test/cronex/table_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Cronex.TableTest do 2 | use ExUnit.Case 3 | doctest Cronex.Table, except: [add_job: 2, get_jobs: 1] 4 | 5 | alias Cronex.Job 6 | 7 | defmodule(TestScheduler, do: use(Cronex.Scheduler)) 8 | 9 | setup do 10 | Process.flag(:trap_exit, true) 11 | {:ok, table} = Cronex.Table.start_link(scheduler: TestScheduler) 12 | {:ok, table: table} 13 | end 14 | 15 | describe "start_link/1 & start_link/2" do 16 | test "raises when no scheduler is given" do 17 | Cronex.Table.start_link(nil) 18 | 19 | assert_receive {:EXIT, _from, reason} 20 | assert %ArgumentError{message: message} = elem(reason, 0) 21 | assert message =~ "No scheduler was provided" 22 | 23 | Cronex.Table.start_link(scheduler: nil) 24 | 25 | assert_receive {:EXIT, _from, reason} 26 | assert %ArgumentError{message: message} = elem(reason, 0) 27 | assert message =~ "No scheduler was provided" 28 | end 29 | end 30 | 31 | describe "add_job/2" do 32 | test "returns :ok", %{table: table} do 33 | task = fn -> IO.puts("Task") end 34 | job = Cronex.Job.new(:day, task) 35 | 36 | assert :ok == Cronex.Table.add_job(table, job) 37 | end 38 | end 39 | 40 | describe "get_jobs/1" do 41 | test "with no jobs returns %{}", %{table: table} do 42 | assert %{} == Cronex.Table.get_jobs(table) 43 | end 44 | 45 | test "with one job returns %{0 => %Job{}}", %{table: table} do 46 | task = fn -> :ok end 47 | job = Job.new(:day, task) 48 | 49 | assert :ok == Cronex.Table.add_job(table, job) 50 | 51 | assert %{0 => table_job} = Cronex.Table.get_jobs(table) 52 | assert table_job == job 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /test/cronex_test.exs: -------------------------------------------------------------------------------- 1 | defmodule CronexTest do 2 | use ExUnit.Case 3 | doctest Cronex 4 | end 5 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | Cronex.Test.DateTime.start_link() 2 | 3 | Application.put_env(:cronex, :date_time_provider, Cronex.Test.DateTime) 4 | Application.put_env(:cronex, :ping_interval, 100) 5 | 6 | ExUnit.start() 7 | --------------------------------------------------------------------------------